@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
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { LitElement, html, nothing } from "lit";
12
+ import type { TemplateResult } from "lit";
12
13
  import { classMap } from "lit/directives/class-map.js";
13
14
  import { ifDefined } from "lit/directives/if-defined.js";
14
15
  import { unsafeSVG } from "lit/directives/unsafe-svg.js";
@@ -26,6 +27,7 @@ import type {
26
27
  ComposeFormat,
27
28
  ComposeLabels,
28
29
  ComposeAttachment,
30
+ ComposeRowStatus,
29
31
  AttachedTextItem,
30
32
  ComposeEditorSelection,
31
33
  ComposeFullscreenOpenDetail,
@@ -35,11 +37,7 @@ import {
35
37
  promoteLeadingH1Title as getLeadingH1TitlePromotion,
36
38
  type LeadingH1TitlePromotion,
37
39
  } from "./compose-title-from-heading.js";
38
- import {
39
- UPLOAD_ACCEPT,
40
- getMediaCategory,
41
- validateUploadFile,
42
- } from "../../lib/upload.js";
40
+ import { getMediaCategory, validateUploadFile } from "../../lib/upload.js";
43
41
  import type { MediaCategory } from "../../lib/upload.js";
44
42
  import {
45
43
  DECORATIVE_QUOTE_MARK_SVG_CONTENT,
@@ -69,6 +67,13 @@ interface ComposeFilePickerCloseDetail {
69
67
  cancelled: boolean;
70
68
  }
71
69
 
70
+ /**
71
+ * Which control a reordered attachment hands focus back to. The strip renders
72
+ * positionally, so without this the focused DOM slot silently retargets a
73
+ * different attachment after every move.
74
+ */
75
+ type AttachmentFocusTarget = "handle" | "move";
76
+
72
77
  const COMPOSE_TOOLBAR_ICONS = {
73
78
  media: `
74
79
  <rect x="2.75" y="3" width="12.5" height="11.25" rx="3" />
@@ -97,43 +102,44 @@ const COMPOSE_TOOLBAR_ICONS = {
97
102
  />
98
103
  <path
99
104
  d="m9 1.95 2.08 4.21 4.65.67-3.36 3.29.8 4.63L9 12.55l-4.17 2.2.8-4.63-3.36-3.29 4.65-.67z"
100
- stroke-width="1.6"
101
105
  />
102
106
  `,
107
+ /* The one filled glyph in the row, so its bars are cut to the weight the
108
+ stroked icons read at — a 2.05 bar beside a 1.42 stroke looks bold. */
103
109
  title: `
104
110
  <rect
105
111
  x="3.35"
106
- y="3.2"
112
+ y="3.37"
107
113
  width="11.3"
108
- height="2.05"
109
- rx="0.68"
114
+ height="1.72"
115
+ rx="0.6"
110
116
  fill="currentColor"
111
117
  stroke="none"
112
118
  />
113
119
  <rect
114
- x="7.8"
120
+ x="7.99"
115
121
  y="4.6"
116
- width="2.4"
122
+ width="2.02"
117
123
  height="9.45"
118
- rx="0.78"
124
+ rx="0.68"
119
125
  fill="currentColor"
120
126
  stroke="none"
121
127
  />
122
128
  <rect
123
129
  x="6.75"
124
- y="13.15"
130
+ y="13.32"
125
131
  width="4.5"
126
- height="1.12"
127
- rx="0.56"
132
+ height="0.95"
133
+ rx="0.48"
128
134
  fill="currentColor"
129
135
  stroke="none"
130
136
  />
131
137
  `,
132
138
  fullscreen: `
133
- <path d="M5.85 3H3v2.85" stroke-width="1.48" />
134
- <path d="M12.15 3H15v2.85" stroke-width="1.48" />
135
- <path d="M3 12.15V15h2.85" stroke-width="1.48" />
136
- <path d="M15 12.15V15h-2.85" stroke-width="1.48" />
139
+ <path d="M5.85 3H3v2.85" />
140
+ <path d="M12.15 3H15v2.85" />
141
+ <path d="M3 12.15V15h2.85" />
142
+ <path d="M15 12.15V15h-2.85" />
137
143
  `,
138
144
  } as const;
139
145
 
@@ -147,7 +153,6 @@ function renderComposeToolbarIcon(
147
153
  viewBox="0 0 18 18"
148
154
  fill="none"
149
155
  stroke="currentColor"
150
- stroke-width="1.55"
151
156
  stroke-linecap="round"
152
157
  stroke-linejoin="round"
153
158
  aria-hidden="true"
@@ -189,6 +194,10 @@ export class JantComposeEditor extends LitElement {
189
194
  threadItem: { type: Boolean, attribute: "thread-item" },
190
195
  removable: { type: Boolean },
191
196
  inlineFormat: { type: Boolean, attribute: "inline-format" },
197
+ titleByDefault: { type: Boolean, attribute: "title-by-default" },
198
+ positionLabel: { type: String, attribute: "position-label" },
199
+ badgeLabel: { type: String, attribute: "badge-label" },
200
+ headerExtra: { attribute: false },
192
201
  slashCommandDiscovered: { type: Boolean },
193
202
  _title: { state: true },
194
203
  _bodyJson: { state: true },
@@ -215,6 +224,29 @@ export class JantComposeEditor extends LitElement {
215
224
  declare threadItem: boolean;
216
225
  declare removable: boolean;
217
226
  declare inlineFormat: boolean;
227
+ /**
228
+ * Whether a note's title field starts visible. A post that opens a thread is
229
+ * usually the one worth naming, so it defaults on there; a reply or a
230
+ * continuation post carries the thread's title already, so it defaults off.
231
+ * On a new single post the owner narrows it further with the author's own
232
+ * remembered answer. Either way the `T` tool overrides it, and a post that
233
+ * arrives with a title always shows the field.
234
+ */
235
+ declare titleByDefault: boolean;
236
+ /**
237
+ * "1/3"-style position marker shown before the format selector. Empty for a
238
+ * single post, where there is no "which one" to answer.
239
+ */
240
+ declare positionLabel: string;
241
+ /** Status marker for the header row, e.g. "Editing". Mutually exclusive with
242
+ * `positionLabel` in practice — a thread post is never an edit. */
243
+ declare badgeLabel: string;
244
+ /**
245
+ * Owner-supplied content for the right side of the format header — the
246
+ * dialog's per-post date/permalink control. Passed in rather than built here
247
+ * because publish metadata belongs to the dialog, not to the editor.
248
+ */
249
+ declare headerExtra: unknown;
218
250
  declare slashCommandDiscovered: boolean;
219
251
  declare _title: string;
220
252
  declare _bodyJson: JSONContent | null;
@@ -243,7 +275,6 @@ export class JantComposeEditor extends LitElement {
243
275
  private _emojiContainer: HTMLElement | null = null;
244
276
  private readonly _urlStatusId = `compose-url-status-${randomUUID()}`;
245
277
  private _onDocClickBound = this._onDocumentClick.bind(this);
246
- private _scrollBufferApplied = false;
247
278
  private _filePickerCleanup: (() => void) | null = null;
248
279
  private _suppressAttachedTextOpenUntil = 0;
249
280
  /**
@@ -254,12 +285,24 @@ export class JantComposeEditor extends LitElement {
254
285
  * to run this cycle.
255
286
  */
256
287
  private _suppressContentChangedOnce = false;
288
+ /**
289
+ * Set when the title toggle arrives with the post rather than from the
290
+ * owner's default — a handed-over editor keeps the answer it was given
291
+ * instead of having the first update reseed it from `titleByDefault`.
292
+ */
293
+ #showTitleHandedOver = false;
257
294
  #inlineImageUploadGeneration = 0;
258
295
  #inlineImageUploadPromises = new Set<Promise<void>>();
259
296
  #rehostFailureCount = 0;
260
297
  #rehostFailureNoticeTimer: ReturnType<typeof setTimeout> | undefined;
261
298
  #sortable: { destroy(): void } | null = null;
262
299
  #revertNextSibling: globalThis.Node | null = null;
300
+ #focusAfterMove: {
301
+ clientId: string;
302
+ direction: -1 | 1;
303
+ focusTarget: AttachmentFocusTarget;
304
+ } | null = null;
305
+ #coarsePointerQuery: globalThis.MediaQueryList | null | undefined;
263
306
 
264
307
  createRenderRoot() {
265
308
  return this;
@@ -273,6 +316,10 @@ export class JantComposeEditor extends LitElement {
273
316
  this.threadItem = false;
274
317
  this.removable = false;
275
318
  this.inlineFormat = false;
319
+ this.titleByDefault = true;
320
+ this.positionLabel = "";
321
+ this.badgeLabel = "";
322
+ this.headerExtra = null;
276
323
  this.slashCommandDiscovered = false;
277
324
  this._title = "";
278
325
  this._bodyJson = null;
@@ -280,7 +327,7 @@ export class JantComposeEditor extends LitElement {
280
327
  this._quoteText = "";
281
328
  this._quoteAuthor = "";
282
329
  this._rating = 0;
283
- this._showTitle = false;
330
+ this._showTitle = true;
284
331
  this._showRating = false;
285
332
  this._attachedTexts = [];
286
333
  this._attachments = [];
@@ -307,6 +354,10 @@ export class JantComposeEditor extends LitElement {
307
354
  this.addEventListener("dragover", this._onDragOver, true);
308
355
  this.addEventListener("dragleave", this._onDragLeave);
309
356
  this.addEventListener("drop", this._onDrop);
357
+ this.#coarsePointer()?.addEventListener(
358
+ "change",
359
+ this._onPointerTypeChange,
360
+ );
310
361
  }
311
362
 
312
363
  disconnectedCallback() {
@@ -331,6 +382,10 @@ export class JantComposeEditor extends LitElement {
331
382
  this._emojiPickerEl = null;
332
383
  this._filePickerCleanup?.();
333
384
  this._filePickerCleanup = null;
385
+ this.#coarsePointer()?.removeEventListener(
386
+ "change",
387
+ this._onPointerTypeChange,
388
+ );
334
389
  }
335
390
 
336
391
  // Tracks dragenter/dragleave nesting so the highlight only clears when the
@@ -778,9 +833,90 @@ export class JantComposeEditor extends LitElement {
778
833
  }
779
834
  }
780
835
 
836
+ /**
837
+ * What this row contributes to the composer's submit state.
838
+ *
839
+ * The editor owns these fields, so it owns the answer: the dialog reduces
840
+ * `getStatus()` over the rows it has rather than reading editors back out of
841
+ * the DOM, which during a render still holds the row it is about to remove.
842
+ *
843
+ * @returns Whether the row holds anything worth keeping, and whether it is
844
+ * complete enough to publish
845
+ * @example
846
+ * editor.getStatus(); // { hasContent: true, publishable: false } — link, no URL yet
847
+ */
848
+ getStatus(): ComposeRowStatus {
849
+ const data = this.getData();
850
+ const hasContent =
851
+ !!data.body ||
852
+ !!data.title.trim() ||
853
+ !!data.url.trim() ||
854
+ !!data.quoteText.trim() ||
855
+ !!data.quoteAuthor.trim() ||
856
+ data.rating > 0 ||
857
+ data.attachments.length > 0 ||
858
+ data.attachedTexts.length > 0;
859
+ const fieldsValid =
860
+ !this.getUrlValidationMessage() && !this.getLinkTitleValidationMessage();
861
+
862
+ let publishable: boolean;
863
+ switch (this.format) {
864
+ // A link is its URL and title, both of which the validators already
865
+ // require — nothing else has to be filled in.
866
+ case "link":
867
+ publishable = fieldsValid;
868
+ break;
869
+ case "quote":
870
+ publishable = fieldsValid && !!data.quoteText.trim();
871
+ break;
872
+ default:
873
+ publishable = fieldsValid && hasContent;
874
+ }
875
+
876
+ return { hasContent, publishable };
877
+ }
878
+
879
+ #lastStatus: ComposeRowStatus | null = null;
880
+
881
+ /** Tell the owner when — and only when — this row's answer changes. */
882
+ #notifyStatus() {
883
+ const status = this.getStatus();
884
+ if (
885
+ this.#lastStatus?.hasContent === status.hasContent &&
886
+ this.#lastStatus?.publishable === status.publishable
887
+ ) {
888
+ return;
889
+ }
890
+ this.#lastStatus = status;
891
+ this.dispatchEvent(
892
+ new CustomEvent<ComposeRowStatus>("jant:compose-status", {
893
+ bubbles: true,
894
+ detail: status,
895
+ }),
896
+ );
897
+ }
898
+
899
+ /**
900
+ * Set the title toggle's default and apply it now.
901
+ *
902
+ * `titleByDefault` alone only takes effect on the first update or on
903
+ * `reset()`, and an owner that resets before Lit re-renders would seed from
904
+ * the previous session's value. An owner that knows the default up front
905
+ * calls this instead.
906
+ *
907
+ * @param show - Whether a note's title field starts visible
908
+ * @example
909
+ * editor.setTitleDefault(false); // next new note opens without a title field
910
+ */
911
+ setTitleDefault(show: boolean) {
912
+ this.titleByDefault = show;
913
+ this._showTitle = show;
914
+ }
915
+
781
916
  reset() {
782
917
  this.#clearPendingInlineImageUploads();
783
918
  this._title = "";
919
+ this._showTitle = this.titleByDefault;
784
920
  this._bodyJson = null;
785
921
  this._editor?.commands.clearContent();
786
922
  this._lastEditorSelection = null;
@@ -788,7 +924,6 @@ export class JantComposeEditor extends LitElement {
788
924
  this._quoteText = "";
789
925
  this._quoteAuthor = "";
790
926
  this._rating = 0;
791
- this._showTitle = false;
792
927
  this._showRating = false;
793
928
  this._attachedTexts = [];
794
929
  // Revoke preview URLs before clearing
@@ -811,9 +946,12 @@ export class JantComposeEditor extends LitElement {
811
946
  status: ComposeAttachment["status"],
812
947
  mediaId: string | null,
813
948
  error: string | null,
949
+ remoteUrl?: string | null,
814
950
  ) {
815
951
  this._attachments = this._attachments.map((a) =>
816
- a.clientId === clientId ? { ...a, status, mediaId, error } : a,
952
+ a.clientId === clientId
953
+ ? { ...a, status, mediaId, error, remoteUrl: remoteUrl ?? a.remoteUrl }
954
+ : a,
817
955
  );
818
956
  }
819
957
 
@@ -911,20 +1049,6 @@ export class JantComposeEditor extends LitElement {
911
1049
  return this._title.trim() ? null : this.labels.linkTitleRequired;
912
1050
  }
913
1051
 
914
- private _getInlineUrlValidationMessage(): string | null {
915
- if (this.format === "link" && !this._url.trim()) {
916
- return null;
917
- }
918
- return this.getUrlValidationMessage();
919
- }
920
-
921
- private _getInlineLinkTitleValidationMessage(): string | null {
922
- if (this.format !== "link" || !this._title.trim()) {
923
- return null;
924
- }
925
- return this.getLinkTitleValidationMessage();
926
- }
927
-
928
1052
  revealUrlValidation(): string | null {
929
1053
  this._showUrlValidation = true;
930
1054
  return this.getUrlValidationMessage();
@@ -978,7 +1102,6 @@ export class JantComposeEditor extends LitElement {
978
1102
  toolbarMode: "compose",
979
1103
  onUpdate: (json) => {
980
1104
  this._bodyJson = json;
981
- this._ensureScrollBuffer();
982
1105
  this.promoteLeadingH1Title();
983
1106
  hideSlashCommandHint(this);
984
1107
  },
@@ -1009,36 +1132,12 @@ export class JantComposeEditor extends LitElement {
1009
1132
  tableControlLabels: this.labels.tableControls,
1010
1133
  });
1011
1134
  this._lastEditorSelection = this._readEditorSelection();
1012
-
1013
- // Lock editor min-height once so new lines fill existing space
1014
- // instead of growing the dialog line-by-line.
1015
- // Skip in page mode where the editor grows freely with the page.
1016
- this._scrollBufferApplied = false;
1017
- const dom = this._editor.view.dom as HTMLElement;
1018
- const isPageMode = !!this.closest(".compose-page-shell");
1019
- if (!isPageMode) {
1020
- const last = dom.lastElementChild as HTMLElement | null;
1021
- const contentH = last ? last.offsetTop + last.offsetHeight : 0;
1022
- const buffer = this.format !== "note" ? 60 : 120;
1023
- dom.style.minHeight = `${contentH + buffer}px`;
1024
- }
1025
- }
1026
-
1027
- /**
1028
- * One-time: adds bottom padding for scroll buffer once the
1029
- * compose-body starts scrolling. Since the dialog is already at
1030
- * max-height by that point, the extra padding doesn't grow it.
1031
- */
1032
- private _ensureScrollBuffer() {
1033
- if (this._scrollBufferApplied) return;
1034
- const dom = this._editor?.view?.dom as HTMLElement | undefined;
1035
- if (!dom) return;
1036
- const body = this.querySelector(".compose-body") as HTMLElement | null;
1037
- if (!body) return;
1038
- if (body.scrollHeight > body.clientHeight + 20) {
1039
- dom.style.paddingBottom = "40px";
1040
- this._scrollBufferApplied = true;
1041
- }
1135
+ // An editor built from existing content — after a format switch, or on a
1136
+ // replacement element that took over a post — inherits the uploads still
1137
+ // working on that content. Without this the `blob:` placeholder is left
1138
+ // with nothing watching it: the finished upload has no editor to write
1139
+ // into, and publishing puts the blob URL in the post.
1140
+ this.adoptPendingUploads();
1042
1141
  }
1043
1142
 
1044
1143
  private _destroyEditor() {
@@ -1061,6 +1160,17 @@ export class JantComposeEditor extends LitElement {
1061
1160
  "_attachmentOrder",
1062
1161
  ]);
1063
1162
 
1163
+ protected willUpdate(changed: Map<string, unknown>) {
1164
+ super.willUpdate(changed);
1165
+
1166
+ // `titleByDefault` is an owner-supplied prop, so it is only known once the
1167
+ // first update runs. Seed the toggle from it there and leave it alone after
1168
+ // that — from then on the value belongs to the user (and to `reset()`).
1169
+ if (!this.hasUpdated && !this.#showTitleHandedOver) {
1170
+ this._showTitle = this.titleByDefault;
1171
+ }
1172
+ }
1173
+
1064
1174
  protected updated(changed: Map<string, unknown>) {
1065
1175
  super.updated(changed);
1066
1176
 
@@ -1096,8 +1206,14 @@ export class JantComposeEditor extends LitElement {
1096
1206
  this.#sortable?.destroy();
1097
1207
  this.#sortable = null;
1098
1208
  }
1209
+ this.#restoreAttachmentFocus();
1099
1210
  }
1100
1211
 
1212
+ // Every field that can change this row's answer is reactive, so `updated()`
1213
+ // is where the owner hears about it — including the ones a user edit never
1214
+ // touches, like an upload finishing.
1215
+ this.#notifyStatus();
1216
+
1101
1217
  // Notify parent dialog of content changes for draft auto-save. A format
1102
1218
  // conversion writes content fields too, but it's not a user edit, so skip
1103
1219
  // the notification once when asked.
@@ -1158,6 +1274,63 @@ export class JantComposeEditor extends LitElement {
1158
1274
  this._bodyJson = fields.bodyJson;
1159
1275
  }
1160
1276
 
1277
+ /**
1278
+ * Take over everything another editor holds for its post.
1279
+ *
1280
+ * Entering or leaving thread mode renders a different editor element for the
1281
+ * same post, and the element being replaced has the only copy of what was
1282
+ * written. Every field that belongs to the post moves here, in this one
1283
+ * place: a caller picking fields out by hand drops whichever one it forgets,
1284
+ * which is how a quote's source link used to vanish on "Add to thread".
1285
+ * Fields hidden by the current format move too, so switching back still
1286
+ * finds them.
1287
+ *
1288
+ * Safe before or after this editor's first render, and on a `previous` that
1289
+ * has already left the DOM — its TipTap instance is gone by then, but
1290
+ * `_bodyJson` tracks every edit.
1291
+ *
1292
+ * @param previous - The editor this one replaces
1293
+ * @example
1294
+ * await dialog.updateComplete;
1295
+ * threadRootEditor.takeOver(singlePostEditor);
1296
+ */
1297
+ takeOver(previous: JantComposeEditor) {
1298
+ this._title = previous._title;
1299
+ this._showTitle = previous._showTitle;
1300
+ this.#showTitleHandedOver = true;
1301
+ this._bodyJson = previous._editor?.getJSON() ?? previous._bodyJson;
1302
+ this._url = previous._url;
1303
+ this._quoteText = previous._quoteText;
1304
+ this._quoteAuthor = previous._quoteAuthor;
1305
+ this._rating = previous._rating;
1306
+ this._showRating = previous._showRating;
1307
+ this._attachedTexts = [...previous._attachedTexts];
1308
+ this._attachments = [...previous._attachments];
1309
+ this._attachmentOrder = [...previous._attachmentOrder];
1310
+ this._lastEditorSelection = previous.getEditorSelection();
1311
+ // Before the first render there is no TipTap instance yet; `_initEditor`
1312
+ // builds it from `_bodyJson` and adopts the uploads then.
1313
+ if (this._editor) {
1314
+ this._editor.commands.setContent(
1315
+ this._bodyJson ?? { type: "doc", content: [{ type: "paragraph" }] },
1316
+ );
1317
+ this.adoptPendingUploads();
1318
+ }
1319
+ this.#notifyStatus();
1320
+ }
1321
+
1322
+ /**
1323
+ * Whether this editor holds the attachment an upload is working on.
1324
+ *
1325
+ * @param clientId - The attachment's client-side id
1326
+ * @returns True when the attachment strip includes it
1327
+ * @example
1328
+ * editor.hasAttachment(clientId); // false once the post moved to another editor
1329
+ */
1330
+ hasAttachment(clientId: string): boolean {
1331
+ return this._attachments.some((a) => a.clientId === clientId);
1332
+ }
1333
+
1161
1334
  /** Pre-fill all fields for edit mode or draft restore */
1162
1335
  populate(data: {
1163
1336
  format: string;
@@ -1171,6 +1344,8 @@ export class JantComposeEditor extends LitElement {
1171
1344
  showRating?: boolean;
1172
1345
  media?: Array<{
1173
1346
  id: string;
1347
+ /** Preserved so a restored draft's attachmentOrder still maps by clientId */
1348
+ clientId?: string;
1174
1349
  previewUrl: string;
1175
1350
  posterUrl?: string | null;
1176
1351
  alt?: string;
@@ -1196,6 +1371,8 @@ export class JantComposeEditor extends LitElement {
1196
1371
  this._rating = data.rating;
1197
1372
  this._showRating = true;
1198
1373
  }
1374
+ // A post that already has a title shows the field whatever the default is —
1375
+ // otherwise editing a titled reply would silently drop its title.
1199
1376
  if (data.showTitle !== undefined) this._showTitle = data.showTitle;
1200
1377
  else if (data.title && data.format === "note") this._showTitle = true;
1201
1378
  if (data.showRating !== undefined) this._showRating = data.showRating;
@@ -1217,13 +1394,14 @@ export class JantComposeEditor extends LitElement {
1217
1394
  // Convert media attachments to ComposeAttachment[] with status "done"
1218
1395
  if (data.media?.length) {
1219
1396
  const attachments = data.media.map((m) => ({
1220
- clientId: randomUUID(),
1397
+ clientId: m.clientId ?? randomUUID(),
1221
1398
  file: new File([], m.originalName ?? "existing", { type: m.mimeType }),
1222
1399
  previewUrl: m.previewUrl,
1223
1400
  posterUrl: m.posterUrl ?? null,
1224
1401
  status: "done" as const,
1225
1402
  progress: null,
1226
1403
  mediaId: m.id,
1404
+ remoteUrl: m.previewUrl,
1227
1405
  alt: m.alt ?? "",
1228
1406
  error: null,
1229
1407
  summary: m.summary ?? null,
@@ -1281,6 +1459,7 @@ export class JantComposeEditor extends LitElement {
1281
1459
 
1282
1460
  this._showUrlValidation = false;
1283
1461
  this._showLinkTitleValidation = false;
1462
+ this.#notifyStatus();
1284
1463
  }
1285
1464
 
1286
1465
  /** Updates editor content and title from fullscreen close */
@@ -1304,6 +1483,10 @@ export class JantComposeEditor extends LitElement {
1304
1483
  );
1305
1484
  }
1306
1485
  this._lastEditorSelection = selection ?? this._readEditorSelection();
1486
+ // Content handed in from outside has to be visible to the owner in the same
1487
+ // tick: fullscreen's ⌘↵ hands the text back and publishes on the next line,
1488
+ // with no render in between to carry the news.
1489
+ this.#notifyStatus();
1307
1490
  }
1308
1491
 
1309
1492
  private static SUMMARY_LENGTH = 100;
@@ -1332,7 +1515,11 @@ export class JantComposeEditor extends LitElement {
1332
1515
  );
1333
1516
  }
1334
1517
 
1335
- private _moveAttachment(clientId: string, direction: -1 | 1) {
1518
+ private _moveAttachment(
1519
+ clientId: string,
1520
+ direction: -1 | 1,
1521
+ focusTarget: AttachmentFocusTarget = "handle",
1522
+ ) {
1336
1523
  const index = this._attachmentOrder.indexOf(clientId);
1337
1524
  const nextIndex = index + direction;
1338
1525
  if (
@@ -1348,9 +1535,50 @@ export class JantComposeEditor extends LitElement {
1348
1535
  if (!item) return;
1349
1536
  nextOrder.splice(nextIndex, 0, item);
1350
1537
  this._attachmentOrder = nextOrder;
1538
+ this.#focusAfterMove = { clientId, direction, focusTarget };
1351
1539
  this.#scrollAttachmentIntoView(clientId);
1352
1540
  }
1353
1541
 
1542
+ /**
1543
+ * Returns focus to the attachment that just moved.
1544
+ *
1545
+ * Lit reuses the strip's DOM slots, so the element that held focus now
1546
+ * renders a different attachment. Called from `updated()`, once the new
1547
+ * order is in the DOM.
1548
+ */
1549
+ #restoreAttachmentFocus() {
1550
+ const pending = this.#focusAfterMove;
1551
+ if (!pending) return;
1552
+ this.#focusAfterMove = null;
1553
+
1554
+ const item = this.querySelector<HTMLElement>(
1555
+ `[data-attachment-id="${pending.clientId}"]`,
1556
+ );
1557
+ if (!item) return;
1558
+
1559
+ if (pending.focusTarget === "handle") {
1560
+ item
1561
+ .querySelector<HTMLElement>("[data-attachment-sortable]")
1562
+ ?.focus({ preventScroll: true });
1563
+ return;
1564
+ }
1565
+
1566
+ // The pressed button turns disabled at either end of the strip, and a
1567
+ // disabled button cannot hold focus — fall back to its twin so the
1568
+ // keyboard path never dead-ends.
1569
+ const target =
1570
+ this.#findMoveButton(item, pending.direction) ??
1571
+ this.#findMoveButton(item, pending.direction === 1 ? -1 : 1);
1572
+ target?.focus({ preventScroll: true });
1573
+ }
1574
+
1575
+ #findMoveButton(item: HTMLElement, direction: -1 | 1) {
1576
+ const button = item.querySelector<HTMLButtonElement>(
1577
+ `[data-attachment-move="${direction === -1 ? "earlier" : "later"}"]`,
1578
+ );
1579
+ return button && !button.disabled ? button : null;
1580
+ }
1581
+
1354
1582
  private _handleAttachmentKeydown(
1355
1583
  clientId: string,
1356
1584
  e: globalThis.KeyboardEvent,
@@ -1374,13 +1602,45 @@ export class JantComposeEditor extends LitElement {
1374
1602
  }
1375
1603
  }
1376
1604
 
1605
+ /**
1606
+ * Matches when the primary pointer is a finger.
1607
+ *
1608
+ * Cached because `matchMedia` allocates a new list each call, and `undefined`
1609
+ * distinguishes "not looked up yet" from "this environment has no
1610
+ * `matchMedia`".
1611
+ */
1612
+ #coarsePointer(): globalThis.MediaQueryList | null {
1613
+ if (this.#coarsePointerQuery !== undefined) return this.#coarsePointerQuery;
1614
+ this.#coarsePointerQuery =
1615
+ typeof window.matchMedia === "function"
1616
+ ? window.matchMedia("(pointer: coarse)")
1617
+ : null;
1618
+ return this.#coarsePointerQuery;
1619
+ }
1620
+
1621
+ private _onPointerTypeChange = () => {
1622
+ this.#sortable?.destroy();
1623
+ this.#sortable = null;
1624
+ this.#initSortable();
1625
+ };
1626
+
1377
1627
  #initSortable() {
1378
1628
  const list = this.querySelector<HTMLElement>("[data-attachment-list]");
1379
1629
  if (!list || this.#sortable || this._attachmentOrder.length <= 1) return;
1380
1630
 
1631
+ // Touch reorders through the move buttons, never by dragging. This strip
1632
+ // scrolls along the axis it would drag, and a near-full-width thumbnail's
1633
+ // drop target sits off-screen — so on a touch device the gesture is worth
1634
+ // more as a plain scroll than as a reorder.
1635
+ if (this.#coarsePointer()?.matches) return;
1636
+
1381
1637
  this.#sortable = Sortable.create(list, {
1382
1638
  ...responsiveSortableOptions,
1383
1639
  chosenClass: "compose-attachment-chosen",
1640
+ // Only reachable on a fine-pointer device that also has a touchscreen,
1641
+ // where the scroll-versus-drag conflict above still applies to touch.
1642
+ // Longer than the shared default so an ambiguous gesture scrolls.
1643
+ delay: 250,
1384
1644
  direction: "horizontal",
1385
1645
  dragClass: "compose-attachment-drag",
1386
1646
  filter:
@@ -1528,13 +1788,78 @@ export class JantComposeEditor extends LitElement {
1528
1788
  el.style.height = `${el.scrollHeight}px`;
1529
1789
  }
1530
1790
 
1791
+ /**
1792
+ * Where the current pointer gesture over the body went down and came up. A
1793
+ * `click` reports the nearest common ancestor of the two, so these are the
1794
+ * only way to tell a press on blank paper from a drag that merely started or
1795
+ * ended there.
1796
+ */
1797
+ #bodyPressTarget: globalThis.EventTarget | null = null;
1798
+ #bodyReleaseTarget: globalThis.EventTarget | null = null;
1799
+
1800
+ private _recordBodyPress = (e: MouseEvent) => {
1801
+ this.#bodyPressTarget = e.target;
1802
+ this.#bodyReleaseTarget = null;
1803
+ };
1804
+
1805
+ private _recordBodyRelease = (e: MouseEvent) => {
1806
+ this.#bodyReleaseTarget = e.target;
1807
+ };
1808
+
1809
+ /**
1810
+ * Puts the caret in the body when the click landed on blank section instead
1811
+ * of on the editor. The surfaces that reserve height — the `/new` page, the
1812
+ * dialog on a phone — leave a stretch of empty paper under a two-line
1813
+ * editor, and it should behave like paper: click it, write there. Only the
1814
+ * layout wrappers forward; a click that reached a real control belongs to
1815
+ * that control.
1816
+ *
1817
+ * Selecting a line right-to-left normally lifts the pointer off the text and
1818
+ * over one of those wrappers, and the resulting click is retargeted to the
1819
+ * wrapper — indistinguishable from a click on blank paper by its target
1820
+ * alone. Requiring the press and the release to have landed on that same
1821
+ * blank space keeps a drag-selection from being thrown away.
1822
+ */
1823
+ private _focusBodyFromBlankSpace = (e: MouseEvent) => {
1824
+ const pressTarget = this.#bodyPressTarget;
1825
+ const releaseTarget = this.#bodyReleaseTarget;
1826
+ this.#bodyPressTarget = null;
1827
+ this.#bodyReleaseTarget = null;
1828
+ if (pressTarget !== e.target || releaseTarget !== e.target) return;
1829
+
1830
+ const target = e.target as HTMLElement | null;
1831
+ if (!target) return;
1832
+ const forwards =
1833
+ target.classList.contains("compose-body") ||
1834
+ target.classList.contains("compose-field-enter") ||
1835
+ target.classList.contains("compose-tiptap-wrap");
1836
+ if (!forwards) return;
1837
+ this._editor?.commands.focus("end");
1838
+ };
1839
+
1531
1840
  private _setRating(star: number) {
1532
1841
  this._rating = this._rating === star ? 0 : star;
1533
1842
  }
1534
1843
 
1844
+ /**
1845
+ * Where a pasted or dropped image lands: inline in the body, or in the
1846
+ * attachment dock.
1847
+ *
1848
+ * Only a note has an article mode to fall into, and a visible, filled title
1849
+ * is what says the body is prose an image belongs inside. A link's body is
1850
+ * commentary beside a link card and a quote's is commentary beside the
1851
+ * quotation — images there are attachments, always. `_title` couldn't answer
1852
+ * this for them anyway: a link's title is required, so it is never empty
1853
+ * once the post is publishable, and a quote has no title field at all yet
1854
+ * can still be holding one carried over from a format switch.
1855
+ *
1856
+ * Only the default moves — the slash `/image` command inserts inline in
1857
+ * every format.
1858
+ */
1535
1859
  private _shouldPasteInlineImage(file: File): boolean {
1536
1860
  if (!file.type.startsWith("image/")) return false;
1537
- if (this.format === "note" && !this._showTitle) return false;
1861
+ if (this.format !== "note") return false;
1862
+ if (!this._showTitle) return false;
1538
1863
  return this._title.trim().length > 0;
1539
1864
  }
1540
1865
 
@@ -1542,7 +1867,9 @@ export class JantComposeEditor extends LitElement {
1542
1867
  if (!this._fileInput) {
1543
1868
  this._fileInput = document.createElement("input");
1544
1869
  this._fileInput.type = "file";
1545
- this._fileInput.accept = UPLOAD_ACCEPT;
1870
+ // No `accept`: the composer takes any file, and leaving the attribute
1871
+ // off is how HTML says so. `*/*` is not a valid accept token, so every
1872
+ // browser had to work out what it meant before the dialog could open.
1546
1873
  this._fileInput.multiple = true;
1547
1874
  this._fileInput.style.display = "none";
1548
1875
  this._fileInput.addEventListener("change", () =>
@@ -1588,6 +1915,7 @@ export class JantComposeEditor extends LitElement {
1588
1915
  status: "pending",
1589
1916
  progress: null,
1590
1917
  mediaId: null,
1918
+ remoteUrl: null,
1591
1919
  alt: "",
1592
1920
  error: null,
1593
1921
  summary: null,
@@ -1959,15 +2287,17 @@ export class JantComposeEditor extends LitElement {
1959
2287
  <span class="compose-attachment-preview-fallback-label">
1960
2288
  ${unavailableLabel}
1961
2289
  </span>
1962
- ${category === "video"
1963
- ? html`
1964
- <div class="compose-attachment-play-icon">
1965
- <svg width="24" height="24" viewBox="0 0 24 24" fill="white">
1966
- <path d="M8 5v14l11-7z" />
1967
- </svg>
1968
- </div>
1969
- `
1970
- : nothing}
2290
+ ${
2291
+ category === "video"
2292
+ ? html`
2293
+ <div class="compose-attachment-play-icon">
2294
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="white">
2295
+ <path d="M8 5v14l11-7z" />
2296
+ </svg>
2297
+ </div>
2298
+ `
2299
+ : nothing
2300
+ }
1971
2301
  </div>
1972
2302
  `;
1973
2303
  }
@@ -1977,30 +2307,19 @@ export class JantComposeEditor extends LitElement {
1977
2307
  private _renderNoteFields() {
1978
2308
  return html`
1979
2309
  <div class="compose-field-enter">
1980
- ${this._showTitle
1981
- ? html`
1982
- <div class="compose-note-title-row">
1983
- <input
1984
- type="text"
1985
- .value=${this._title}
1986
- @input=${(e: Event) => this._onInput("_title", e)}
1987
- @focus=${(e: Event) => this._onFieldFocus(e)}
1988
- @keydown=${this._handleTitleKeydown}
1989
- class="compose-input compose-note-title"
1990
- placeholder=${this.labels.titlePlaceholder}
1991
- />
1992
- <button
1993
- type="button"
1994
- class="compose-note-title-dismiss"
1995
- @click=${() => {
1996
- this._showTitle = false;
1997
- }}
1998
- >
1999
- ✕
2000
- </button>
2001
- </div>
2002
- `
2003
- : nothing}
2310
+ ${
2311
+ this._showTitle
2312
+ ? html`<input
2313
+ type="text"
2314
+ .value=${this._title}
2315
+ @input=${(e: Event) => this._onInput("_title", e)}
2316
+ @focus=${(e: Event) => this._onFieldFocus(e)}
2317
+ @keydown=${this._handleTitleKeydown}
2318
+ class="compose-input compose-note-title"
2319
+ placeholder=${this.labels.titlePlaceholder}
2320
+ />`
2321
+ : nothing
2322
+ }
2004
2323
  <div class="compose-tiptap-wrap">
2005
2324
  <div class="compose-tiptap-body"></div>
2006
2325
  <span class="compose-slash-discovery-hint" aria-hidden="true">
@@ -2013,10 +2332,10 @@ export class JantComposeEditor extends LitElement {
2013
2332
 
2014
2333
  private _renderLinkFields() {
2015
2334
  const urlError = this._showUrlValidation
2016
- ? this._getInlineUrlValidationMessage()
2335
+ ? this.getUrlValidationMessage()
2017
2336
  : null;
2018
2337
  const titleError = this._showLinkTitleValidation
2019
- ? this._getInlineLinkTitleValidationMessage()
2338
+ ? this.getLinkTitleValidationMessage()
2020
2339
  : null;
2021
2340
 
2022
2341
  return html`
@@ -2034,7 +2353,10 @@ export class JantComposeEditor extends LitElement {
2034
2353
  @input=${(e: Event) => this._onInput("_url", e)}
2035
2354
  @focus=${(e: Event) => this._onFieldFocus(e)}
2036
2355
  @blur=${() => {
2037
- this._showUrlValidation = true;
2356
+ // Leaving a field empty is not yet a mistake — it becomes one at
2357
+ // submit, where revealUrlValidation() opens this gate anyway.
2358
+ // Blur only speaks up about something already typed and wrong.
2359
+ if (this._url.trim()) this._showUrlValidation = true;
2038
2360
  }}
2039
2361
  class="compose-input compose-url-input"
2040
2362
  placeholder=${this.labels.urlPlaceholder}
@@ -2044,15 +2366,17 @@ export class JantComposeEditor extends LitElement {
2044
2366
  )}
2045
2367
  />
2046
2368
  </div>
2047
- ${urlError
2048
- ? html`<p
2049
- id=${this._urlStatusId}
2050
- class="compose-url-status compose-url-status-error"
2051
- data-compose-url-status="error"
2052
- >
2053
- ${urlError}
2054
- </p>`
2055
- : nothing}
2369
+ ${
2370
+ urlError
2371
+ ? html`<p
2372
+ id=${this._urlStatusId}
2373
+ class="compose-url-status compose-url-status-error"
2374
+ data-compose-url-status="error"
2375
+ >
2376
+ ${urlError}
2377
+ </p>`
2378
+ : nothing
2379
+ }
2056
2380
  <input
2057
2381
  type="text"
2058
2382
  .value=${this._title}
@@ -2060,25 +2384,26 @@ export class JantComposeEditor extends LitElement {
2060
2384
  @focus=${(e: Event) => this._onFieldFocus(e)}
2061
2385
  @keydown=${this._handleTitleKeydown}
2062
2386
  @blur=${() => {
2063
- this._showLinkTitleValidation = true;
2387
+ // Same gate as the URL above: quiet while empty, loud on submit.
2388
+ if (this._title.trim()) this._showLinkTitleValidation = true;
2064
2389
  }}
2065
2390
  class="compose-input compose-link-title"
2066
2391
  placeholder=${this.labels.linkTitlePlaceholder}
2067
2392
  aria-invalid=${titleError ? "true" : "false"}
2068
2393
  />
2069
- ${titleError
2070
- ? html`<p
2071
- class="compose-url-status compose-url-status-error"
2072
- data-compose-link-title-error="error"
2073
- >
2074
- ${titleError}
2075
- </p>`
2076
- : nothing}
2394
+ ${
2395
+ titleError
2396
+ ? html`<p
2397
+ class="compose-url-status compose-url-status-error"
2398
+ data-compose-link-title-error="error"
2399
+ >
2400
+ ${titleError}
2401
+ </p>`
2402
+ : nothing
2403
+ }
2077
2404
  <div class="compose-divider"></div>
2078
2405
  <div class="compose-tiptap-wrap">
2079
- <div
2080
- class="compose-tiptap-body compose-tiptap-thoughts compose-tiptap-link"
2081
- ></div>
2406
+ <div class="compose-tiptap-body compose-tiptap-link"></div>
2082
2407
  <span class="compose-slash-discovery-hint" aria-hidden="true">
2083
2408
  ${this.labels.slashHint}
2084
2409
  </span>
@@ -2102,7 +2427,7 @@ export class JantComposeEditor extends LitElement {
2102
2427
  @focus=${(e: Event) => this._onFieldFocus(e)}
2103
2428
  class="compose-input compose-quote-text"
2104
2429
  placeholder=${this.labels.quotePlaceholder}
2105
- rows="5"
2430
+ rows="4"
2106
2431
  ></textarea>
2107
2432
  </div>
2108
2433
  <div class="compose-quote-author-row">
@@ -2132,24 +2457,24 @@ export class JantComposeEditor extends LitElement {
2132
2457
  urlError ? this._urlStatusId : undefined,
2133
2458
  )}
2134
2459
  />
2135
- ${urlError
2136
- ? html`<p
2137
- id=${this._urlStatusId}
2138
- class="compose-url-status compose-url-status-error"
2139
- data-compose-url-status="error"
2140
- >
2141
- ${urlError}
2142
- </p>`
2143
- : nothing}
2460
+ ${
2461
+ urlError
2462
+ ? html`<p
2463
+ id=${this._urlStatusId}
2464
+ class="compose-url-status compose-url-status-error"
2465
+ data-compose-url-status="error"
2466
+ >
2467
+ ${urlError}
2468
+ </p>`
2469
+ : nothing
2470
+ }
2144
2471
  </div>
2145
2472
  <div
2146
2473
  class="compose-divider compose-divider-quote"
2147
2474
  aria-hidden="true"
2148
2475
  ></div>
2149
2476
  <div class="compose-tiptap-wrap">
2150
- <div
2151
- class="compose-tiptap-body compose-tiptap-thoughts compose-tiptap-thoughts-quote"
2152
- ></div>
2477
+ <div class="compose-tiptap-body"></div>
2153
2478
  <span class="compose-slash-discovery-hint" aria-hidden="true">
2154
2479
  ${this.labels.slashHint}
2155
2480
  </span>
@@ -2177,9 +2502,11 @@ export class JantComposeEditor extends LitElement {
2177
2502
  </button>
2178
2503
  `,
2179
2504
  )}
2180
- ${this._rating > 0
2181
- ? html`<span class="compose-star-label">${this._rating}/5</span>`
2182
- : nothing}
2505
+ ${
2506
+ this._rating > 0
2507
+ ? html`<span class="compose-star-label">${this._rating}/5</span>`
2508
+ : nothing
2509
+ }
2183
2510
  </div>
2184
2511
  `;
2185
2512
  }
@@ -2253,16 +2580,20 @@ export class JantComposeEditor extends LitElement {
2253
2580
  ${this._renderFileIcon(a.file.type, 20)}
2254
2581
  </div>
2255
2582
  <span class="compose-attachment-file-name">${a.file.name}</span>
2256
- ${a.summary
2257
- ? html`<span class="compose-attachment-text-summary"
2258
- >${a.summary}</span
2259
- >`
2260
- : nothing}
2261
- ${typeof a.chars === "number" && a.chars > 0
2262
- ? html`<span class="compose-attachment-file-size"
2263
- >${this._formatChars(a.chars)}</span
2264
- >`
2265
- : nothing}
2583
+ ${
2584
+ a.summary
2585
+ ? html`<span class="compose-attachment-text-summary"
2586
+ >${a.summary}</span
2587
+ >`
2588
+ : nothing
2589
+ }
2590
+ ${
2591
+ typeof a.chars === "number" && a.chars > 0
2592
+ ? html`<span class="compose-attachment-file-size"
2593
+ >${this._formatChars(a.chars)}</span
2594
+ >`
2595
+ : nothing
2596
+ }
2266
2597
  </div>
2267
2598
  `;
2268
2599
  }
@@ -2292,46 +2623,52 @@ export class JantComposeEditor extends LitElement {
2292
2623
 
2293
2624
  private _renderAttachmentOverlay(a: ComposeAttachment, index: number) {
2294
2625
  return html`
2295
- ${a.status === "error"
2296
- ? html`
2297
- <button
2298
- type="button"
2299
- class="compose-attachment-overlay compose-attachment-retry"
2300
- @click=${(e: Event) => {
2301
- e.stopPropagation();
2302
- this._retryAllFailed();
2303
- }}
2304
- >
2305
- <span class="compose-retry-content">
2306
- <svg
2307
- width="20"
2308
- height="20"
2309
- viewBox="0 0 24 24"
2310
- fill="none"
2311
- stroke="currentColor"
2312
- stroke-width="2"
2313
- stroke-linecap="round"
2314
- stroke-linejoin="round"
2315
- >
2316
- <path
2317
- d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
2318
- />
2319
- <path d="M3 3v5h5" />
2320
- <path
2321
- d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"
2322
- />
2323
- <path d="M16 16h5v5" />
2324
- </svg>
2325
- <span class="compose-retry-label">${this.labels.retryAll}</span>
2326
- </span>
2327
- ${a.error
2328
- ? html`<span class="compose-attachment-error-msg"
2329
- >${a.error}</span
2330
- >`
2331
- : nothing}
2332
- </button>
2333
- `
2334
- : nothing}
2626
+ ${
2627
+ a.status === "error"
2628
+ ? html`
2629
+ <button
2630
+ type="button"
2631
+ class="compose-attachment-overlay compose-attachment-retry"
2632
+ @click=${(e: Event) => {
2633
+ e.stopPropagation();
2634
+ this._retryAllFailed();
2635
+ }}
2636
+ >
2637
+ <span class="compose-retry-content">
2638
+ <svg
2639
+ width="20"
2640
+ height="20"
2641
+ viewBox="0 0 24 24"
2642
+ fill="none"
2643
+ stroke="currentColor"
2644
+ stroke-width="2"
2645
+ stroke-linecap="round"
2646
+ stroke-linejoin="round"
2647
+ >
2648
+ <path
2649
+ d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
2650
+ />
2651
+ <path d="M3 3v5h5" />
2652
+ <path
2653
+ d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"
2654
+ />
2655
+ <path d="M16 16h5v5" />
2656
+ </svg>
2657
+ <span class="compose-retry-label"
2658
+ >${this.labels.retryAll}</span
2659
+ >
2660
+ </span>
2661
+ ${
2662
+ a.error
2663
+ ? html`<span class="compose-attachment-error-msg"
2664
+ >${a.error}</span
2665
+ >`
2666
+ : nothing
2667
+ }
2668
+ </button>
2669
+ `
2670
+ : nothing
2671
+ }
2335
2672
  ${this._renderAttachmentRemoveButton((e: Event) => {
2336
2673
  e.stopPropagation();
2337
2674
  this._removeAttachment(index);
@@ -2363,6 +2700,87 @@ export class JantComposeEditor extends LitElement {
2363
2700
  `;
2364
2701
  }
2365
2702
 
2703
+ private _renderAttachmentMoveButton(
2704
+ clientId: string,
2705
+ direction: -1 | 1,
2706
+ disabled: boolean,
2707
+ ) {
2708
+ const earlier = direction === -1;
2709
+ const label = earlier
2710
+ ? this.labels.moveAttachmentEarlier
2711
+ : this.labels.moveAttachmentLater;
2712
+
2713
+ return html`
2714
+ <button
2715
+ type="button"
2716
+ class="compose-attachment-move"
2717
+ data-attachment-move=${earlier ? "earlier" : "later"}
2718
+ ?disabled=${disabled}
2719
+ aria-label=${label}
2720
+ title=${label}
2721
+ @click=${(e: Event) => {
2722
+ e.stopPropagation();
2723
+ this._moveAttachment(clientId, direction, "move");
2724
+ }}
2725
+ >
2726
+ <svg
2727
+ width="14"
2728
+ height="14"
2729
+ viewBox="0 0 14 14"
2730
+ fill="none"
2731
+ stroke="currentColor"
2732
+ stroke-width="1.9"
2733
+ stroke-linecap="round"
2734
+ stroke-linejoin="round"
2735
+ >
2736
+ <path
2737
+ d=${
2738
+ earlier ? "M8.75 3.5 5.25 7l3.5 3.5" : "M5.25 3.5 8.75 7l-3.5 3.5"
2739
+ }
2740
+ />
2741
+ </svg>
2742
+ </button>
2743
+ `;
2744
+ }
2745
+
2746
+ /**
2747
+ * Renders the quiet control row under an attachment thumbnail.
2748
+ *
2749
+ * Reorder buttons are the touch-reliable path: dragging a thumbnail competes
2750
+ * with the strip's own horizontal scroll, and a near-full-width thumbnail's
2751
+ * drop target is off-screen anyway. They appear only once there is something
2752
+ * to reorder against.
2753
+ */
2754
+ private _renderAttachmentActions(
2755
+ clientId: string,
2756
+ extra: TemplateResult | typeof nothing = nothing,
2757
+ ) {
2758
+ const total = this._attachmentOrder.length;
2759
+ const index = this._attachmentOrder.indexOf(clientId);
2760
+ const canReorder = total > 1 && index !== -1;
2761
+ if (!canReorder && extra === nothing) return nothing;
2762
+
2763
+ return html`
2764
+ <div class="compose-attachment-actions">
2765
+ ${
2766
+ canReorder
2767
+ ? html`
2768
+ <div class="compose-attachment-move-group">
2769
+ ${this._renderAttachmentMoveButton(clientId, -1, index === 0)}
2770
+ ${this._renderAttachmentMoveButton(
2771
+ clientId,
2772
+ 1,
2773
+ index === total - 1,
2774
+ )}
2775
+ </div>
2776
+ `
2777
+ : nothing
2778
+ }
2779
+ ${extra}
2780
+ </div>
2781
+ `;
2782
+ }
2783
+
2366
2784
  private _renderAttachedTextCard(item: AttachedTextItem, index: number) {
2367
2785
  return html`
2368
2786
  <div class="compose-attachment" data-attachment-id=${item.clientId}>
@@ -2381,19 +2799,22 @@ export class JantComposeEditor extends LitElement {
2381
2799
  ${this._renderFileIcon("text/html; charset=utf-8", 20)}
2382
2800
  </div>
2383
2801
  <span class="compose-attachment-text-summary">${item.summary}</span>
2384
- ${item.bodyJson
2385
- ? html`<span class="compose-attachment-file-size"
2386
- >${this._formatChars(
2387
- this._extractPlainText(item.bodyJson).length,
2388
- )}</span
2389
- >`
2390
- : nothing}
2802
+ ${
2803
+ item.bodyJson
2804
+ ? html`<span class="compose-attachment-file-size"
2805
+ >${this._formatChars(
2806
+ this._extractPlainText(item.bodyJson).length,
2807
+ )}</span
2808
+ >`
2809
+ : nothing
2810
+ }
2391
2811
  </div>
2392
2812
  ${this._renderAttachmentRemoveButton((e: Event) => {
2393
2813
  e.stopPropagation();
2394
2814
  this._removeAttachedText(index);
2395
2815
  })}
2396
2816
  </div>
2817
+ ${this._renderAttachmentActions(item.clientId)}
2397
2818
  </div>
2398
2819
  `;
2399
2820
  }
@@ -2406,115 +2827,122 @@ export class JantComposeEditor extends LitElement {
2406
2827
 
2407
2828
  return html`
2408
2829
  <div class="compose-attachment" data-attachment-id=${a.clientId}>
2409
- ${isFileCard
2410
- ? html`
2411
- <div class="compose-attachment-thumb">
2412
- <div
2413
- class="compose-attachment-sortable"
2414
- data-attachment-sortable
2415
- tabindex="0"
2416
- @keydown=${(e: globalThis.KeyboardEvent) =>
2417
- this._handleAttachmentKeydown(a.clientId, e)}
2418
- >
2419
- ${this._renderAttachmentPreview(a)}
2830
+ ${
2831
+ isFileCard
2832
+ ? html`
2833
+ <div class="compose-attachment-thumb">
2834
+ <div
2835
+ class="compose-attachment-sortable"
2836
+ data-attachment-sortable
2837
+ tabindex="0"
2838
+ @keydown=${(e: globalThis.KeyboardEvent) =>
2839
+ this._handleAttachmentKeydown(a.clientId, e)}
2840
+ >
2841
+ ${this._renderAttachmentPreview(a)}
2842
+ </div>
2843
+ ${this._renderAttachmentOverlay(a, i)}
2420
2844
  </div>
2421
- ${this._renderAttachmentOverlay(a, i)}
2422
- </div>
2423
- `
2424
- : html`
2425
- <div class="compose-attachment-thumb">
2426
- <div
2427
- class="compose-attachment-sortable"
2428
- data-attachment-sortable
2429
- tabindex="0"
2430
- @keydown=${(e: globalThis.KeyboardEvent) =>
2431
- this._handleAttachmentKeydown(a.clientId, e)}
2432
- >
2433
- ${previewFailed
2434
- ? this._renderAttachmentPreviewFallback(visualCategory)
2435
- : category === "video"
2436
- ? html`
2437
- <video
2438
- src=${a.previewUrl}
2439
- poster=${a.posterUrl ?? nothing}
2440
- class="compose-attachment-img"
2441
- preload="metadata"
2442
- .playsInline=${true}
2443
- .muted=${true}
2444
- @loadeddata=${() =>
2445
- this._setAttachmentPreviewFailure(
2446
- a.clientId,
2447
- false,
2448
- )}
2449
- @error=${() =>
2450
- this._setAttachmentPreviewFailure(
2451
- a.clientId,
2452
- true,
2453
- )}
2454
- ></video>
2455
- <div class="compose-attachment-play-icon">
2456
- <svg
2457
- width="24"
2458
- height="24"
2459
- viewBox="0 0 24 24"
2460
- fill="white"
2461
- >
2462
- <path d="M8 5v14l11-7z" />
2463
- </svg>
2464
- </div>
2465
- `
2466
- : a.status === "processing"
2467
- ? html`
2468
- <div class="compose-attachment-processing">
2469
- <svg
2470
- class="animate-spin size-5"
2471
- viewBox="0 0 24 24"
2472
- fill="none"
2473
- stroke="currentColor"
2474
- stroke-width="2"
2475
- >
2476
- <path
2477
- d="M12 2a10 10 0 1 0 10 10"
2478
- stroke-linecap="round"
2845
+ `
2846
+ : html`
2847
+ <div class="compose-attachment-thumb">
2848
+ <div
2849
+ class="compose-attachment-sortable"
2850
+ data-attachment-sortable
2851
+ tabindex="0"
2852
+ @keydown=${(e: globalThis.KeyboardEvent) =>
2853
+ this._handleAttachmentKeydown(a.clientId, e)}
2854
+ >
2855
+ ${
2856
+ previewFailed
2857
+ ? this._renderAttachmentPreviewFallback(visualCategory)
2858
+ : category === "video"
2859
+ ? html`
2860
+ <video
2861
+ src=${a.previewUrl}
2862
+ poster=${a.posterUrl ?? nothing}
2863
+ class="compose-attachment-img"
2864
+ preload="metadata"
2865
+ .playsInline=${true}
2866
+ .muted=${true}
2867
+ @loadeddata=${() =>
2868
+ this._setAttachmentPreviewFailure(
2869
+ a.clientId,
2870
+ false,
2871
+ )}
2872
+ @error=${() =>
2873
+ this._setAttachmentPreviewFailure(
2874
+ a.clientId,
2875
+ true,
2876
+ )}
2877
+ ></video>
2878
+ <div class="compose-attachment-play-icon">
2879
+ <svg
2880
+ width="24"
2881
+ height="24"
2882
+ viewBox="0 0 24 24"
2883
+ fill="white"
2884
+ >
2885
+ <path d="M8 5v14l11-7z" />
2886
+ </svg>
2887
+ </div>
2888
+ `
2889
+ : a.status === "processing"
2890
+ ? html`
2891
+ <div class="compose-attachment-processing">
2892
+ <svg
2893
+ class="animate-spin size-5"
2894
+ viewBox="0 0 24 24"
2895
+ fill="none"
2896
+ stroke="currentColor"
2897
+ stroke-width="2"
2898
+ >
2899
+ <path
2900
+ d="M12 2a10 10 0 1 0 10 10"
2901
+ stroke-linecap="round"
2902
+ />
2903
+ </svg>
2904
+ </div>
2905
+ `
2906
+ : html`
2907
+ <img
2908
+ src=${a.previewUrl}
2909
+ alt=""
2910
+ class="compose-attachment-img"
2911
+ @load=${() =>
2912
+ this._setAttachmentPreviewFailure(
2913
+ a.clientId,
2914
+ false,
2915
+ )}
2916
+ @error=${() =>
2917
+ this._setAttachmentPreviewFailure(
2918
+ a.clientId,
2919
+ true,
2920
+ )}
2479
2921
  />
2480
- </svg>
2481
- </div>
2482
- `
2483
- : html`
2484
- <img
2485
- src=${a.previewUrl}
2486
- alt=""
2487
- class="compose-attachment-img"
2488
- @load=${() =>
2489
- this._setAttachmentPreviewFailure(
2490
- a.clientId,
2491
- false,
2492
- )}
2493
- @error=${() =>
2494
- this._setAttachmentPreviewFailure(
2495
- a.clientId,
2496
- true,
2497
- )}
2498
- />
2499
- `}
2922
+ `
2923
+ }
2924
+ </div>
2925
+ ${this._renderAttachmentOverlay(a, i)}
2500
2926
  </div>
2501
- ${this._renderAttachmentOverlay(a, i)}
2502
- </div>
2503
- `}
2504
- ${category === "image"
2505
- ? html`
2506
- <button
2507
- type="button"
2508
- class=${classMap({
2509
- "compose-attachment-alt": true,
2510
- "compose-attachment-alt-set": a.alt.length > 0,
2511
- })}
2512
- @click=${() => this._openAltPanel(i)}
2513
- >
2514
- ${a.alt.length > 0 ? "ALT" : "+ ALT"}
2515
- </button>
2516
- `
2517
- : nothing}
2927
+ `
2928
+ }
2929
+ ${this._renderAttachmentActions(
2930
+ a.clientId,
2931
+ category === "image"
2932
+ ? html`
2933
+ <button
2934
+ type="button"
2935
+ class=${classMap({
2936
+ "compose-attachment-alt": true,
2937
+ "compose-attachment-alt-set": a.alt.length > 0,
2938
+ })}
2939
+ @click=${() => this._openAltPanel(i)}
2940
+ >
2941
+ ${a.alt.length > 0 ? "ALT" : "+ ALT"}
2942
+ </button>
2943
+ `
2944
+ : nothing,
2945
+ )}
2518
2946
  </div>
2519
2947
  `;
2520
2948
  }
@@ -2573,11 +3001,13 @@ export class JantComposeEditor extends LitElement {
2573
3001
  @click=${() => this._openFilePicker()}
2574
3002
  >
2575
3003
  ${renderComposeToolbarIcon(COMPOSE_TOOLBAR_ICONS.media)}
2576
- ${this._attachments.length > 0
2577
- ? html`<span class="compose-tool-label"
2578
- >${this.labels.addMore}</span
2579
- >`
2580
- : nothing}
3004
+ ${
3005
+ this._attachments.length > 0
3006
+ ? html`<span class="compose-tool-label"
3007
+ >${this.labels.addMore}</span
3008
+ >`
3009
+ : nothing
3010
+ }
2581
3011
  </button>
2582
3012
 
2583
3013
  <button
@@ -2590,11 +3020,13 @@ export class JantComposeEditor extends LitElement {
2590
3020
  @click=${() => this._openAttachedText()}
2591
3021
  >
2592
3022
  ${renderComposeToolbarIcon(COMPOSE_TOOLBAR_ICONS.attachedText)}
2593
- ${hasAttached
2594
- ? html`<span class="compose-tool-label"
2595
- >${this.labels.addMore}</span
2596
- >`
2597
- : nothing}
3023
+ ${
3024
+ hasAttached
3025
+ ? html`<span class="compose-tool-label"
3026
+ >${this.labels.addMore}</span
3027
+ >`
3028
+ : nothing
3029
+ }
2598
3030
  </button>
2599
3031
 
2600
3032
  <button
@@ -2605,12 +3037,13 @@ export class JantComposeEditor extends LitElement {
2605
3037
  "compose-tool-btn-active": this._showEmojiPicker,
2606
3038
  })}
2607
3039
  title=${this.labels.emoji}
3040
+ aria-pressed=${this._showEmojiPicker ? "true" : "false"}
2608
3041
  @click=${() => this._toggleEmojiPicker()}
2609
3042
  >
2610
3043
  ${renderComposeToolbarIcon(COMPOSE_TOOLBAR_ICONS.emoji)}
2611
3044
  </button>
2612
3045
 
2613
- <div class="compose-tool-sep"></div>
3046
+ <div class="compose-tool-gap"></div>
2614
3047
 
2615
3048
  <button
2616
3049
  type="button"
@@ -2619,6 +3052,7 @@ export class JantComposeEditor extends LitElement {
2619
3052
  "compose-tool-btn-active": this._showRating,
2620
3053
  })}
2621
3054
  title=${this.labels.rate}
3055
+ aria-pressed=${this._showRating ? "true" : "false"}
2622
3056
  @click=${() => {
2623
3057
  const willShow = !this._showRating;
2624
3058
  this._showRating = willShow;
@@ -2634,46 +3068,61 @@ export class JantComposeEditor extends LitElement {
2634
3068
  ${renderComposeToolbarIcon(COMPOSE_TOOLBAR_ICONS.rate)}
2635
3069
  </button>
2636
3070
 
2637
- ${this.format === "note"
2638
- ? html`
2639
- <button
2640
- type="button"
2641
- class=${classMap({
2642
- "compose-tool-btn": true,
2643
- "compose-tool-btn-active": this._showTitle,
2644
- })}
2645
- title=${this.labels.title}
2646
- @click=${() => {
2647
- const willShow = !this._showTitle;
2648
- this._showTitle = willShow;
2649
- if (willShow) {
2650
- this.updateComplete.then(() => {
2651
- this.querySelector<HTMLInputElement>(
2652
- ".compose-note-title",
2653
- )?.focus();
2654
- });
2655
- }
2656
- }}
2657
- >
2658
- ${renderComposeToolbarIcon(COMPOSE_TOOLBAR_ICONS.title)}
2659
- </button>
2660
- `
2661
- : nothing}
2662
- ${this.format === "note"
2663
- ? html`
2664
- <div class="compose-tool-view-group">
3071
+ ${
3072
+ this.format === "note"
3073
+ ? html`
2665
3074
  <button
2666
3075
  type="button"
2667
- class="compose-tool-btn compose-tool-btn-view"
2668
- title=${this.labels.fullscreen}
2669
- aria-label=${this.labels.fullscreen}
2670
- @click=${() => this.openFullscreen()}
3076
+ class=${classMap({
3077
+ "compose-tool-btn": true,
3078
+ "compose-tool-btn-active": this._showTitle,
3079
+ })}
3080
+ title=${this.labels.title}
3081
+ aria-pressed=${this._showTitle ? "true" : "false"}
3082
+ @click=${() => {
3083
+ const willShow = !this._showTitle;
3084
+ this._showTitle = willShow;
3085
+ // Only this deliberate click is worth remembering. The other
3086
+ // writers of `_showTitle` — H1 promotion, format conversion,
3087
+ // fullscreen reveal — are the program acting on one post, not
3088
+ // the author stating a habit, so they stay silent.
3089
+ this.dispatchEvent(
3090
+ new CustomEvent("jant:title-toggle", {
3091
+ bubbles: true,
3092
+ detail: { showTitle: willShow },
3093
+ }),
3094
+ );
3095
+ if (willShow) {
3096
+ this.updateComplete.then(() => {
3097
+ this.querySelector<HTMLInputElement>(
3098
+ ".compose-note-title",
3099
+ )?.focus();
3100
+ });
3101
+ }
3102
+ }}
2671
3103
  >
2672
- ${renderComposeToolbarIcon(COMPOSE_TOOLBAR_ICONS.fullscreen)}
3104
+ ${renderComposeToolbarIcon(COMPOSE_TOOLBAR_ICONS.title)}
2673
3105
  </button>
2674
- </div>
2675
- `
2676
- : nothing}
3106
+ `
3107
+ : nothing
3108
+ }
3109
+ ${
3110
+ this.format === "note"
3111
+ ? html`
3112
+ <div class="compose-tool-view-group">
3113
+ <button
3114
+ type="button"
3115
+ class="compose-tool-btn compose-tool-btn-view"
3116
+ title=${this.labels.fullscreen}
3117
+ aria-label=${this.labels.fullscreen}
3118
+ @click=${() => this.openFullscreen()}
3119
+ >
3120
+ ${renderComposeToolbarIcon(COMPOSE_TOOLBAR_ICONS.fullscreen)}
3121
+ </button>
3122
+ </div>
3123
+ `
3124
+ : nothing
3125
+ }
2677
3126
  </div>
2678
3127
  `;
2679
3128
  }
@@ -2689,14 +3138,10 @@ export class JantComposeEditor extends LitElement {
2689
3138
 
2690
3139
  return html`
2691
3140
  <div class="compose-thread-post-header">
2692
- <div class="compose-segmented compose-thread-segmented">
2693
- <div
2694
- class=${classMap({
2695
- "compose-format-pill": true,
2696
- "compose-format-pill-link": this.format === "link",
2697
- "compose-format-pill-quote": this.format === "quote",
2698
- })}
2699
- ></div>
3141
+ <div class="compose-segmented">
3142
+ <!-- No sliding indicator: the items size to their labels now, so a
3143
+ thirds-based pill would not line up. The active item carries its
3144
+ own background instead. -->
2700
3145
  ${formats.map(
2701
3146
  (f) => html`
2702
3147
  <button
@@ -2721,31 +3166,48 @@ export class JantComposeEditor extends LitElement {
2721
3166
  `,
2722
3167
  )}
2723
3168
  </div>
2724
- ${this.removable
2725
- ? html`<button
2726
- type="button"
2727
- class="compose-thread-post-remove"
2728
- title="Remove post"
2729
- @click=${() => {
2730
- this.dispatchEvent(
2731
- new CustomEvent("jant:thread-remove", { bubbles: true }),
2732
- );
2733
- }}
2734
- >
2735
- <svg
2736
- width="14"
2737
- height="14"
2738
- viewBox="0 0 16 16"
2739
- fill="none"
2740
- stroke="currentColor"
2741
- stroke-width="1.8"
2742
- stroke-linecap="round"
2743
- aria-hidden="true"
3169
+ ${
3170
+ this.positionLabel
3171
+ ? html`<span class="compose-post-position"
3172
+ >${this.positionLabel}</span
3173
+ >`
3174
+ : nothing
3175
+ }
3176
+ ${
3177
+ this.badgeLabel
3178
+ ? html`<span class="compose-post-position compose-post-badge"
3179
+ >${this.badgeLabel}</span
3180
+ >`
3181
+ : nothing
3182
+ }
3183
+ ${this.headerExtra ?? nothing}
3184
+ ${
3185
+ this.removable
3186
+ ? html`<button
3187
+ type="button"
3188
+ class="compose-thread-post-remove"
3189
+ title="Remove post"
3190
+ @click=${() => {
3191
+ this.dispatchEvent(
3192
+ new CustomEvent("jant:thread-remove", { bubbles: true }),
3193
+ );
3194
+ }}
2744
3195
  >
2745
- ${unsafeSVG(CLOSE_ICON)}
2746
- </svg>
2747
- </button>`
2748
- : nothing}
3196
+ <svg
3197
+ width="14"
3198
+ height="14"
3199
+ viewBox="0 0 16 16"
3200
+ fill="none"
3201
+ stroke="currentColor"
3202
+ stroke-width="1.8"
3203
+ stroke-linecap="round"
3204
+ aria-hidden="true"
3205
+ >
3206
+ ${unsafeSVG(CLOSE_ICON)}
3207
+ </svg>
3208
+ </button>`
3209
+ : nothing
3210
+ }
2749
3211
  </div>
2750
3212
  `;
2751
3213
  }
@@ -2762,16 +3224,23 @@ export class JantComposeEditor extends LitElement {
2762
3224
  }
2763
3225
 
2764
3226
  render() {
3227
+ // The format selector lives here in every mode — single post, reply, and
3228
+ // thread item alike — so there is exactly one place it can be.
2765
3229
  return html`
2766
- ${this.threadItem || this.inlineFormat
2767
- ? this._renderFormatHeader()
2768
- : nothing}
2769
- <section class="compose-body">
2770
- ${this.format === "note"
2771
- ? this._renderNoteFields()
2772
- : this.format === "link"
2773
- ? this._renderLinkFields()
2774
- : this._renderQuoteFields()}
3230
+ ${this._renderFormatHeader()}
3231
+ <section
3232
+ class="compose-body"
3233
+ @mousedown=${this._recordBodyPress}
3234
+ @mouseup=${this._recordBodyRelease}
3235
+ @click=${this._focusBodyFromBlankSpace}
3236
+ >
3237
+ ${
3238
+ this.format === "note"
3239
+ ? this._renderNoteFields()
3240
+ : this.format === "link"
3241
+ ? this._renderLinkFields()
3242
+ : this._renderQuoteFields()
3243
+ }
2775
3244
  </section>
2776
3245
  ${this._renderAttachmentDock()} ${this._renderStarRating()}
2777
3246
  ${this._renderToolsRow()}