@jant/core 0.6.15 → 0.6.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/bin/commands/import-site.js +7 -0
  2. package/dist/app-C75Zc8dD.js +6 -0
  3. package/dist/{app-C-oi_t8W.js → app-C7_4oab9.js} +480 -196
  4. package/dist/client/.vite/manifest.json +8 -8
  5. package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
  6. package/dist/client/_assets/{client-DxY5BFe8.js → client-Bq0vre8Y.js} +3 -3
  7. package/dist/client/_assets/client-D3VZyG4L.css +2 -0
  8. package/dist/client/_assets/{client-auth-CakPESv9.js → client-auth-F2rb9WfF.js} +1015 -977
  9. package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
  10. package/dist/{export-CsFx6F_M.js → export-Dl5KCiEs.js} +93 -7
  11. package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
  12. package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
  13. package/dist/{github-sync-ppWXN3jb.js → github-sync-Bqg62IvV.js} +3 -3
  14. package/dist/{github-sync-Cq1pzzOI.js → github-sync-HdK2EgvJ.js} +3 -3
  15. package/dist/index.js +5 -5
  16. package/dist/node.js +6 -6
  17. package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
  18. package/package.json +1 -1
  19. package/src/__tests__/mise-config.test.ts +22 -0
  20. package/src/client/__tests__/compose-bridge.test.ts +252 -2
  21. package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
  22. package/src/client/__tests__/toast.test.ts +65 -1
  23. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  24. package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +2015 -318
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +53 -62
  27. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +35 -89
  28. package/src/client/components/compose-format-convert.ts +5 -1
  29. package/src/client/components/compose-types.ts +47 -3
  30. package/src/client/components/jant-command-palette.ts +12 -6
  31. package/src/client/components/jant-compose-dialog.ts +1464 -680
  32. package/src/client/components/jant-compose-editor.ts +225 -93
  33. package/src/client/components/jant-compose-fullscreen.ts +3 -5
  34. package/src/client/components/jant-post-menu.ts +91 -21
  35. package/src/client/compose-bridge.ts +143 -122
  36. package/src/client/compose-launch.ts +13 -0
  37. package/src/client/compose-shortcuts.ts +5 -1
  38. package/src/client/post-refresh.ts +135 -0
  39. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  40. package/src/client/tiptap/link-input-rules.ts +4 -4
  41. package/src/client/toast.ts +130 -38
  42. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  43. package/src/db/__tests__/thread-activity.test.ts +113 -0
  44. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  45. package/src/db/migrations/0031_many_ego.sql +3 -0
  46. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  47. package/src/db/migrations/meta/_journal.json +7 -0
  48. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  49. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  50. package/src/db/migrations/pg/meta/_journal.json +7 -0
  51. package/src/db/pg/schema.ts +18 -0
  52. package/src/db/schema.ts +20 -0
  53. package/src/db/thread-activity.ts +101 -0
  54. package/src/i18n/locales/public/en.po +89 -21
  55. package/src/i18n/locales/public/en.ts +1 -1
  56. package/src/i18n/locales/public/zh-Hans.po +88 -20
  57. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  58. package/src/i18n/locales/public/zh-Hant.po +88 -20
  59. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  60. package/src/lib/__tests__/feed.test.ts +168 -0
  61. package/src/lib/__tests__/markdown.test.ts +16 -0
  62. package/src/lib/__tests__/slug.test.ts +18 -0
  63. package/src/lib/__tests__/translit.test.ts +87 -0
  64. package/src/lib/__tests__/url.test.ts +53 -0
  65. package/src/lib/feed.ts +113 -11
  66. package/src/lib/hugo-markdown.ts +5 -0
  67. package/src/lib/link-preview.ts +25 -0
  68. package/src/lib/markdown-manager.ts +6 -1
  69. package/src/lib/post-display.ts +42 -9
  70. package/src/lib/timeline.ts +39 -9
  71. package/src/lib/tiptap-render.ts +4 -2
  72. package/src/lib/translit.ts +288 -0
  73. package/src/lib/url.ts +123 -12
  74. package/src/lib/view.ts +28 -0
  75. package/src/routes/api/__tests__/palette.test.ts +44 -0
  76. package/src/routes/api/posts.ts +13 -9
  77. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  78. package/src/routes/api/public/posts.ts +7 -0
  79. package/src/routes/compose.tsx +6 -2
  80. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  81. package/src/routes/pages/__tests__/archive-params.test.ts +292 -0
  82. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  83. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  84. package/src/routes/pages/archive.tsx +65 -17
  85. package/src/routes/pages/page.tsx +5 -2
  86. package/src/services/__tests__/collection.test.ts +32 -1
  87. package/src/services/__tests__/post-timeline.test.ts +142 -11
  88. package/src/services/__tests__/post.test.ts +277 -0
  89. package/src/services/collection.ts +8 -10
  90. package/src/services/export-theme/assets/client-site.css +1 -1
  91. package/src/services/export.ts +3 -5
  92. package/src/services/path.ts +4 -1
  93. package/src/services/post.ts +297 -100
  94. package/src/services/search.ts +9 -0
  95. package/src/styles/components.css +0 -14
  96. package/src/styles/site-media.css +4 -4
  97. package/src/styles/tokens.css +62 -1
  98. package/src/styles/ui.css +1363 -1283
  99. package/src/types/entities.ts +5 -0
  100. package/src/types/props.ts +12 -0
  101. package/src/types/views.ts +6 -0
  102. package/src/ui/compose/ComposeDialog.tsx +17 -17
  103. package/src/ui/feed/LinkPreview.tsx +2 -7
  104. package/src/ui/feed/PostStatusBadges.tsx +42 -3
  105. package/src/ui/pages/ArchivePage.tsx +131 -24
  106. package/src/ui/pages/__tests__/ArchivePage.test.tsx +63 -0
  107. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  108. package/src/ui/shared/PostFooter.tsx +35 -12
  109. package/src/ui/shared/custom-icons.ts +5 -0
  110. package/src/ui/shared/post-article-attributes.ts +6 -0
  111. package/dist/app-BxCOR3Uc.js +0 -6
  112. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
@@ -26,6 +26,7 @@ import type {
26
26
  ComposeFormat,
27
27
  ComposeLabels,
28
28
  ComposeAttachment,
29
+ ComposeRowStatus,
29
30
  AttachedTextItem,
30
31
  ComposeEditorSelection,
31
32
  ComposeFullscreenOpenDetail,
@@ -97,43 +98,44 @@ const COMPOSE_TOOLBAR_ICONS = {
97
98
  />
98
99
  <path
99
100
  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
101
  />
102
102
  `,
103
+ /* The one filled glyph in the row, so its bars are cut to the weight the
104
+ stroked icons read at — a 2.05 bar beside a 1.42 stroke looks bold. */
103
105
  title: `
104
106
  <rect
105
107
  x="3.35"
106
- y="3.2"
108
+ y="3.37"
107
109
  width="11.3"
108
- height="2.05"
109
- rx="0.68"
110
+ height="1.72"
111
+ rx="0.6"
110
112
  fill="currentColor"
111
113
  stroke="none"
112
114
  />
113
115
  <rect
114
- x="7.8"
116
+ x="7.99"
115
117
  y="4.6"
116
- width="2.4"
118
+ width="2.02"
117
119
  height="9.45"
118
- rx="0.78"
120
+ rx="0.68"
119
121
  fill="currentColor"
120
122
  stroke="none"
121
123
  />
122
124
  <rect
123
125
  x="6.75"
124
- y="13.15"
126
+ y="13.32"
125
127
  width="4.5"
126
- height="1.12"
127
- rx="0.56"
128
+ height="0.95"
129
+ rx="0.48"
128
130
  fill="currentColor"
129
131
  stroke="none"
130
132
  />
131
133
  `,
132
134
  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" />
135
+ <path d="M5.85 3H3v2.85" />
136
+ <path d="M12.15 3H15v2.85" />
137
+ <path d="M3 12.15V15h2.85" />
138
+ <path d="M15 12.15V15h-2.85" />
137
139
  `,
138
140
  } as const;
139
141
 
@@ -147,7 +149,6 @@ function renderComposeToolbarIcon(
147
149
  viewBox="0 0 18 18"
148
150
  fill="none"
149
151
  stroke="currentColor"
150
- stroke-width="1.55"
151
152
  stroke-linecap="round"
152
153
  stroke-linejoin="round"
153
154
  aria-hidden="true"
@@ -189,6 +190,10 @@ export class JantComposeEditor extends LitElement {
189
190
  threadItem: { type: Boolean, attribute: "thread-item" },
190
191
  removable: { type: Boolean },
191
192
  inlineFormat: { type: Boolean, attribute: "inline-format" },
193
+ titleByDefault: { type: Boolean, attribute: "title-by-default" },
194
+ positionLabel: { type: String, attribute: "position-label" },
195
+ badgeLabel: { type: String, attribute: "badge-label" },
196
+ headerExtra: { attribute: false },
192
197
  slashCommandDiscovered: { type: Boolean },
193
198
  _title: { state: true },
194
199
  _bodyJson: { state: true },
@@ -215,6 +220,29 @@ export class JantComposeEditor extends LitElement {
215
220
  declare threadItem: boolean;
216
221
  declare removable: boolean;
217
222
  declare inlineFormat: boolean;
223
+ /**
224
+ * Whether a note's title field starts visible. A post that opens a thread is
225
+ * usually the one worth naming, so it defaults on there; a reply or a
226
+ * continuation post carries the thread's title already, so it defaults off.
227
+ * On a new single post the owner narrows it further with the author's own
228
+ * remembered answer. Either way the `T` tool overrides it, and a post that
229
+ * arrives with a title always shows the field.
230
+ */
231
+ declare titleByDefault: boolean;
232
+ /**
233
+ * "1/3"-style position marker shown before the format selector. Empty for a
234
+ * single post, where there is no "which one" to answer.
235
+ */
236
+ declare positionLabel: string;
237
+ /** Status marker for the header row, e.g. "Editing". Mutually exclusive with
238
+ * `positionLabel` in practice — a thread post is never an edit. */
239
+ declare badgeLabel: string;
240
+ /**
241
+ * Owner-supplied content for the right side of the format header — the
242
+ * dialog's per-post date/permalink control. Passed in rather than built here
243
+ * because publish metadata belongs to the dialog, not to the editor.
244
+ */
245
+ declare headerExtra: unknown;
218
246
  declare slashCommandDiscovered: boolean;
219
247
  declare _title: string;
220
248
  declare _bodyJson: JSONContent | null;
@@ -243,7 +271,6 @@ export class JantComposeEditor extends LitElement {
243
271
  private _emojiContainer: HTMLElement | null = null;
244
272
  private readonly _urlStatusId = `compose-url-status-${randomUUID()}`;
245
273
  private _onDocClickBound = this._onDocumentClick.bind(this);
246
- private _scrollBufferApplied = false;
247
274
  private _filePickerCleanup: (() => void) | null = null;
248
275
  private _suppressAttachedTextOpenUntil = 0;
249
276
  /**
@@ -273,6 +300,10 @@ export class JantComposeEditor extends LitElement {
273
300
  this.threadItem = false;
274
301
  this.removable = false;
275
302
  this.inlineFormat = false;
303
+ this.titleByDefault = true;
304
+ this.positionLabel = "";
305
+ this.badgeLabel = "";
306
+ this.headerExtra = null;
276
307
  this.slashCommandDiscovered = false;
277
308
  this._title = "";
278
309
  this._bodyJson = null;
@@ -280,7 +311,7 @@ export class JantComposeEditor extends LitElement {
280
311
  this._quoteText = "";
281
312
  this._quoteAuthor = "";
282
313
  this._rating = 0;
283
- this._showTitle = false;
314
+ this._showTitle = true;
284
315
  this._showRating = false;
285
316
  this._attachedTexts = [];
286
317
  this._attachments = [];
@@ -778,9 +809,90 @@ export class JantComposeEditor extends LitElement {
778
809
  }
779
810
  }
780
811
 
812
+ /**
813
+ * What this row contributes to the composer's submit state.
814
+ *
815
+ * The editor owns these fields, so it owns the answer: the dialog reduces
816
+ * `getStatus()` over the rows it has rather than reading editors back out of
817
+ * the DOM, which during a render still holds the row it is about to remove.
818
+ *
819
+ * @returns Whether the row holds anything worth keeping, and whether it is
820
+ * complete enough to publish
821
+ * @example
822
+ * editor.getStatus(); // { hasContent: true, publishable: false } — link, no URL yet
823
+ */
824
+ getStatus(): ComposeRowStatus {
825
+ const data = this.getData();
826
+ const hasContent =
827
+ !!data.body ||
828
+ !!data.title.trim() ||
829
+ !!data.url.trim() ||
830
+ !!data.quoteText.trim() ||
831
+ !!data.quoteAuthor.trim() ||
832
+ data.rating > 0 ||
833
+ data.attachments.length > 0 ||
834
+ data.attachedTexts.length > 0;
835
+ const fieldsValid =
836
+ !this.getUrlValidationMessage() && !this.getLinkTitleValidationMessage();
837
+
838
+ let publishable: boolean;
839
+ switch (this.format) {
840
+ // A link is its URL and title, both of which the validators already
841
+ // require — nothing else has to be filled in.
842
+ case "link":
843
+ publishable = fieldsValid;
844
+ break;
845
+ case "quote":
846
+ publishable = fieldsValid && !!data.quoteText.trim();
847
+ break;
848
+ default:
849
+ publishable = fieldsValid && hasContent;
850
+ }
851
+
852
+ return { hasContent, publishable };
853
+ }
854
+
855
+ #lastStatus: ComposeRowStatus | null = null;
856
+
857
+ /** Tell the owner when — and only when — this row's answer changes. */
858
+ #notifyStatus() {
859
+ const status = this.getStatus();
860
+ if (
861
+ this.#lastStatus?.hasContent === status.hasContent &&
862
+ this.#lastStatus?.publishable === status.publishable
863
+ ) {
864
+ return;
865
+ }
866
+ this.#lastStatus = status;
867
+ this.dispatchEvent(
868
+ new CustomEvent<ComposeRowStatus>("jant:compose-status", {
869
+ bubbles: true,
870
+ detail: status,
871
+ }),
872
+ );
873
+ }
874
+
875
+ /**
876
+ * Set the title toggle's default and apply it now.
877
+ *
878
+ * `titleByDefault` alone only takes effect on the first update or on
879
+ * `reset()`, and an owner that resets before Lit re-renders would seed from
880
+ * the previous session's value. An owner that knows the default up front
881
+ * calls this instead.
882
+ *
883
+ * @param show - Whether a note's title field starts visible
884
+ * @example
885
+ * editor.setTitleDefault(false); // next new note opens without a title field
886
+ */
887
+ setTitleDefault(show: boolean) {
888
+ this.titleByDefault = show;
889
+ this._showTitle = show;
890
+ }
891
+
781
892
  reset() {
782
893
  this.#clearPendingInlineImageUploads();
783
894
  this._title = "";
895
+ this._showTitle = this.titleByDefault;
784
896
  this._bodyJson = null;
785
897
  this._editor?.commands.clearContent();
786
898
  this._lastEditorSelection = null;
@@ -788,7 +900,6 @@ export class JantComposeEditor extends LitElement {
788
900
  this._quoteText = "";
789
901
  this._quoteAuthor = "";
790
902
  this._rating = 0;
791
- this._showTitle = false;
792
903
  this._showRating = false;
793
904
  this._attachedTexts = [];
794
905
  // Revoke preview URLs before clearing
@@ -811,9 +922,12 @@ export class JantComposeEditor extends LitElement {
811
922
  status: ComposeAttachment["status"],
812
923
  mediaId: string | null,
813
924
  error: string | null,
925
+ remoteUrl?: string | null,
814
926
  ) {
815
927
  this._attachments = this._attachments.map((a) =>
816
- a.clientId === clientId ? { ...a, status, mediaId, error } : a,
928
+ a.clientId === clientId
929
+ ? { ...a, status, mediaId, error, remoteUrl: remoteUrl ?? a.remoteUrl }
930
+ : a,
817
931
  );
818
932
  }
819
933
 
@@ -978,7 +1092,6 @@ export class JantComposeEditor extends LitElement {
978
1092
  toolbarMode: "compose",
979
1093
  onUpdate: (json) => {
980
1094
  this._bodyJson = json;
981
- this._ensureScrollBuffer();
982
1095
  this.promoteLeadingH1Title();
983
1096
  hideSlashCommandHint(this);
984
1097
  },
@@ -1009,36 +1122,6 @@ export class JantComposeEditor extends LitElement {
1009
1122
  tableControlLabels: this.labels.tableControls,
1010
1123
  });
1011
1124
  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
- }
1042
1125
  }
1043
1126
 
1044
1127
  private _destroyEditor() {
@@ -1061,6 +1144,17 @@ export class JantComposeEditor extends LitElement {
1061
1144
  "_attachmentOrder",
1062
1145
  ]);
1063
1146
 
1147
+ protected willUpdate(changed: Map<string, unknown>) {
1148
+ super.willUpdate(changed);
1149
+
1150
+ // `titleByDefault` is an owner-supplied prop, so it is only known once the
1151
+ // first update runs. Seed the toggle from it there and leave it alone after
1152
+ // that — from then on the value belongs to the user (and to `reset()`).
1153
+ if (!this.hasUpdated) {
1154
+ this._showTitle = this.titleByDefault;
1155
+ }
1156
+ }
1157
+
1064
1158
  protected updated(changed: Map<string, unknown>) {
1065
1159
  super.updated(changed);
1066
1160
 
@@ -1098,6 +1192,11 @@ export class JantComposeEditor extends LitElement {
1098
1192
  }
1099
1193
  }
1100
1194
 
1195
+ // Every field that can change this row's answer is reactive, so `updated()`
1196
+ // is where the owner hears about it — including the ones a user edit never
1197
+ // touches, like an upload finishing.
1198
+ this.#notifyStatus();
1199
+
1101
1200
  // Notify parent dialog of content changes for draft auto-save. A format
1102
1201
  // conversion writes content fields too, but it's not a user edit, so skip
1103
1202
  // the notification once when asked.
@@ -1171,6 +1270,8 @@ export class JantComposeEditor extends LitElement {
1171
1270
  showRating?: boolean;
1172
1271
  media?: Array<{
1173
1272
  id: string;
1273
+ /** Preserved so a restored draft's attachmentOrder still maps by clientId */
1274
+ clientId?: string;
1174
1275
  previewUrl: string;
1175
1276
  posterUrl?: string | null;
1176
1277
  alt?: string;
@@ -1196,6 +1297,8 @@ export class JantComposeEditor extends LitElement {
1196
1297
  this._rating = data.rating;
1197
1298
  this._showRating = true;
1198
1299
  }
1300
+ // A post that already has a title shows the field whatever the default is —
1301
+ // otherwise editing a titled reply would silently drop its title.
1199
1302
  if (data.showTitle !== undefined) this._showTitle = data.showTitle;
1200
1303
  else if (data.title && data.format === "note") this._showTitle = true;
1201
1304
  if (data.showRating !== undefined) this._showRating = data.showRating;
@@ -1217,13 +1320,14 @@ export class JantComposeEditor extends LitElement {
1217
1320
  // Convert media attachments to ComposeAttachment[] with status "done"
1218
1321
  if (data.media?.length) {
1219
1322
  const attachments = data.media.map((m) => ({
1220
- clientId: randomUUID(),
1323
+ clientId: m.clientId ?? randomUUID(),
1221
1324
  file: new File([], m.originalName ?? "existing", { type: m.mimeType }),
1222
1325
  previewUrl: m.previewUrl,
1223
1326
  posterUrl: m.posterUrl ?? null,
1224
1327
  status: "done" as const,
1225
1328
  progress: null,
1226
1329
  mediaId: m.id,
1330
+ remoteUrl: m.previewUrl,
1227
1331
  alt: m.alt ?? "",
1228
1332
  error: null,
1229
1333
  summary: m.summary ?? null,
@@ -1281,6 +1385,7 @@ export class JantComposeEditor extends LitElement {
1281
1385
 
1282
1386
  this._showUrlValidation = false;
1283
1387
  this._showLinkTitleValidation = false;
1388
+ this.#notifyStatus();
1284
1389
  }
1285
1390
 
1286
1391
  /** Updates editor content and title from fullscreen close */
@@ -1304,6 +1409,10 @@ export class JantComposeEditor extends LitElement {
1304
1409
  );
1305
1410
  }
1306
1411
  this._lastEditorSelection = selection ?? this._readEditorSelection();
1412
+ // Content handed in from outside has to be visible to the owner in the same
1413
+ // tick: fullscreen's ⌘↵ hands the text back and publishes on the next line,
1414
+ // with no render in between to carry the news.
1415
+ this.#notifyStatus();
1307
1416
  }
1308
1417
 
1309
1418
  private static SUMMARY_LENGTH = 100;
@@ -1528,6 +1637,25 @@ export class JantComposeEditor extends LitElement {
1528
1637
  el.style.height = `${el.scrollHeight}px`;
1529
1638
  }
1530
1639
 
1640
+ /**
1641
+ * Puts the caret in the body when the click landed on blank section instead
1642
+ * of on the editor. The surfaces that reserve height — the `/new` page, the
1643
+ * dialog on a phone — leave a stretch of empty paper under a two-line
1644
+ * editor, and it should behave like paper: click it, write there. Only the
1645
+ * layout wrappers forward; a click that reached a real control belongs to
1646
+ * that control.
1647
+ */
1648
+ private _focusBodyFromBlankSpace = (e: MouseEvent) => {
1649
+ const target = e.target as HTMLElement | null;
1650
+ if (!target) return;
1651
+ const forwards =
1652
+ target.classList.contains("compose-body") ||
1653
+ target.classList.contains("compose-field-enter") ||
1654
+ target.classList.contains("compose-tiptap-wrap");
1655
+ if (!forwards) return;
1656
+ this._editor?.commands.focus("end");
1657
+ };
1658
+
1531
1659
  private _setRating(star: number) {
1532
1660
  this._rating = this._rating === star ? 0 : star;
1533
1661
  }
@@ -1588,6 +1716,7 @@ export class JantComposeEditor extends LitElement {
1588
1716
  status: "pending",
1589
1717
  progress: null,
1590
1718
  mediaId: null,
1719
+ remoteUrl: null,
1591
1720
  alt: "",
1592
1721
  error: null,
1593
1722
  summary: null,
@@ -1978,28 +2107,15 @@ export class JantComposeEditor extends LitElement {
1978
2107
  return html`
1979
2108
  <div class="compose-field-enter">
1980
2109
  ${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
- `
2110
+ ? html`<input
2111
+ type="text"
2112
+ .value=${this._title}
2113
+ @input=${(e: Event) => this._onInput("_title", e)}
2114
+ @focus=${(e: Event) => this._onFieldFocus(e)}
2115
+ @keydown=${this._handleTitleKeydown}
2116
+ class="compose-input compose-note-title"
2117
+ placeholder=${this.labels.titlePlaceholder}
2118
+ />`
2003
2119
  : nothing}
2004
2120
  <div class="compose-tiptap-wrap">
2005
2121
  <div class="compose-tiptap-body"></div>
@@ -2076,9 +2192,7 @@ export class JantComposeEditor extends LitElement {
2076
2192
  : nothing}
2077
2193
  <div class="compose-divider"></div>
2078
2194
  <div class="compose-tiptap-wrap">
2079
- <div
2080
- class="compose-tiptap-body compose-tiptap-thoughts compose-tiptap-link"
2081
- ></div>
2195
+ <div class="compose-tiptap-body compose-tiptap-link"></div>
2082
2196
  <span class="compose-slash-discovery-hint" aria-hidden="true">
2083
2197
  ${this.labels.slashHint}
2084
2198
  </span>
@@ -2102,7 +2216,7 @@ export class JantComposeEditor extends LitElement {
2102
2216
  @focus=${(e: Event) => this._onFieldFocus(e)}
2103
2217
  class="compose-input compose-quote-text"
2104
2218
  placeholder=${this.labels.quotePlaceholder}
2105
- rows="5"
2219
+ rows="4"
2106
2220
  ></textarea>
2107
2221
  </div>
2108
2222
  <div class="compose-quote-author-row">
@@ -2147,9 +2261,7 @@ export class JantComposeEditor extends LitElement {
2147
2261
  aria-hidden="true"
2148
2262
  ></div>
2149
2263
  <div class="compose-tiptap-wrap">
2150
- <div
2151
- class="compose-tiptap-body compose-tiptap-thoughts compose-tiptap-thoughts-quote"
2152
- ></div>
2264
+ <div class="compose-tiptap-body"></div>
2153
2265
  <span class="compose-slash-discovery-hint" aria-hidden="true">
2154
2266
  ${this.labels.slashHint}
2155
2267
  </span>
@@ -2605,12 +2717,13 @@ export class JantComposeEditor extends LitElement {
2605
2717
  "compose-tool-btn-active": this._showEmojiPicker,
2606
2718
  })}
2607
2719
  title=${this.labels.emoji}
2720
+ aria-pressed=${this._showEmojiPicker ? "true" : "false"}
2608
2721
  @click=${() => this._toggleEmojiPicker()}
2609
2722
  >
2610
2723
  ${renderComposeToolbarIcon(COMPOSE_TOOLBAR_ICONS.emoji)}
2611
2724
  </button>
2612
2725
 
2613
- <div class="compose-tool-sep"></div>
2726
+ <div class="compose-tool-gap"></div>
2614
2727
 
2615
2728
  <button
2616
2729
  type="button"
@@ -2619,6 +2732,7 @@ export class JantComposeEditor extends LitElement {
2619
2732
  "compose-tool-btn-active": this._showRating,
2620
2733
  })}
2621
2734
  title=${this.labels.rate}
2735
+ aria-pressed=${this._showRating ? "true" : "false"}
2622
2736
  @click=${() => {
2623
2737
  const willShow = !this._showRating;
2624
2738
  this._showRating = willShow;
@@ -2643,9 +2757,20 @@ export class JantComposeEditor extends LitElement {
2643
2757
  "compose-tool-btn-active": this._showTitle,
2644
2758
  })}
2645
2759
  title=${this.labels.title}
2760
+ aria-pressed=${this._showTitle ? "true" : "false"}
2646
2761
  @click=${() => {
2647
2762
  const willShow = !this._showTitle;
2648
2763
  this._showTitle = willShow;
2764
+ // Only this deliberate click is worth remembering. The other
2765
+ // writers of `_showTitle` — H1 promotion, format conversion,
2766
+ // fullscreen reveal — are the program acting on one post, not
2767
+ // the author stating a habit, so they stay silent.
2768
+ this.dispatchEvent(
2769
+ new CustomEvent("jant:title-toggle", {
2770
+ bubbles: true,
2771
+ detail: { showTitle: willShow },
2772
+ }),
2773
+ );
2649
2774
  if (willShow) {
2650
2775
  this.updateComplete.then(() => {
2651
2776
  this.querySelector<HTMLInputElement>(
@@ -2689,14 +2814,10 @@ export class JantComposeEditor extends LitElement {
2689
2814
 
2690
2815
  return html`
2691
2816
  <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>
2817
+ <div class="compose-segmented">
2818
+ <!-- No sliding indicator: the items size to their labels now, so a
2819
+ thirds-based pill would not line up. The active item carries its
2820
+ own background instead. -->
2700
2821
  ${formats.map(
2701
2822
  (f) => html`
2702
2823
  <button
@@ -2721,6 +2842,17 @@ export class JantComposeEditor extends LitElement {
2721
2842
  `,
2722
2843
  )}
2723
2844
  </div>
2845
+ ${this.positionLabel
2846
+ ? html`<span class="compose-post-position"
2847
+ >${this.positionLabel}</span
2848
+ >`
2849
+ : nothing}
2850
+ ${this.badgeLabel
2851
+ ? html`<span class="compose-post-position compose-post-badge"
2852
+ >${this.badgeLabel}</span
2853
+ >`
2854
+ : nothing}
2855
+ ${this.headerExtra ?? nothing}
2724
2856
  ${this.removable
2725
2857
  ? html`<button
2726
2858
  type="button"
@@ -2762,11 +2894,11 @@ export class JantComposeEditor extends LitElement {
2762
2894
  }
2763
2895
 
2764
2896
  render() {
2897
+ // The format selector lives here in every mode — single post, reply, and
2898
+ // thread item alike — so there is exactly one place it can be.
2765
2899
  return html`
2766
- ${this.threadItem || this.inlineFormat
2767
- ? this._renderFormatHeader()
2768
- : nothing}
2769
- <section class="compose-body">
2900
+ ${this._renderFormatHeader()}
2901
+ <section class="compose-body" @click=${this._focusBodyFromBlankSpace}>
2770
2902
  ${this.format === "note"
2771
2903
  ? this._renderNoteFields()
2772
2904
  : this.format === "link"
@@ -59,7 +59,7 @@ export class JantComposeFullscreen extends LitElement {
59
59
  this.labels = {} as ComposeLabels;
60
60
  this._open = false;
61
61
  this._title = "";
62
- this._showTitle = false;
62
+ this._showTitle = true;
63
63
  this._replyContext = null;
64
64
  this._replyExpanded = false;
65
65
  }
@@ -314,9 +314,7 @@ export class JantComposeFullscreen extends LitElement {
314
314
  class=${placeholderClasses}
315
315
  @click=${() => this._revealTitle()}
316
316
  >
317
- ${variant === "reply"
318
- ? this.labels.titlePlaceholder || this.labels.title || "Title"
319
- : this.labels.title || "Title"}
317
+ ${this.labels.titlePlaceholder || this.labels.title || "Title"}
320
318
  </button>
321
319
  `;
322
320
  }
@@ -412,7 +410,7 @@ export class JantComposeFullscreen extends LitElement {
412
410
  </button>
413
411
  </div>
414
412
  <div
415
- class="compose-editor-row compose-fullscreen-editor-row"
413
+ class="compose-editor-row compose-fullscreen-editor-row is-current"
416
414
  >
417
415
  <div class="compose-thread-dot"></div>
418
416
  ${editorSurface("reply")}