@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
@@ -69,6 +69,45 @@ async function flushUpdates(el?: JantComposeDialog) {
69
69
  }
70
70
  }
71
71
 
72
+ async function openPublishPanel(el: JantComposeDialog) {
73
+ requireElement(
74
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
75
+ "expected publish settings toggle",
76
+ ).click();
77
+ await flushUpdates(el);
78
+ }
79
+
80
+ /** Options-panel rows are found by their title — the drafts entries moved off
81
+ * the title bar and into that panel. */
82
+ function sheetRow(
83
+ el: JantComposeDialog,
84
+ title: string,
85
+ ): HTMLButtonElement | null {
86
+ return (
87
+ Array.from(
88
+ el.querySelectorAll<HTMLButtonElement>(
89
+ ".compose-publish-panel .compose-sheet-row",
90
+ ),
91
+ ).find(
92
+ (row) =>
93
+ row.querySelector(".compose-sheet-title")?.textContent?.trim() ===
94
+ title,
95
+ ) ?? null
96
+ );
97
+ }
98
+
99
+ function sheetRowTitles(el: JantComposeDialog): (string | undefined)[] {
100
+ return Array.from(
101
+ el.querySelectorAll<HTMLElement>(
102
+ ".compose-publish-panel .compose-sheet-title",
103
+ ),
104
+ ).map((n) => n.textContent?.trim());
105
+ }
106
+
107
+ function draftsRow(el: JantComposeDialog): HTMLButtonElement | null {
108
+ return sheetRow(el, "Drafts");
109
+ }
110
+
72
111
  function deferred<T>() {
73
112
  let resolve!: (value: T) => void;
74
113
  const promise = new Promise<T>((res) => {
@@ -211,13 +250,13 @@ const labels: ComposeLabels = {
211
250
  publishDateInvalid: "Enter a valid date.",
212
251
  publishDateFutureError:
213
252
  "Choose today or an earlier date, or leave it blank to publish now.",
214
- publishDateSummaryNow: "Publish now",
253
+ publishDateSummaryNow: "Now",
215
254
  publishDateSummaryAction: "Edit publish date",
216
255
  publishSlugLabel: "Custom link",
217
256
  publishSlugPlaceholder: "your-post-link",
218
257
  publishSlugHint: "Leave blank to generate one automatically.",
219
258
  publishSlugAuto: "Generate automatically",
220
- publishSlugSummaryAuto: "Auto link",
259
+ publishSlugSummaryAuto: "Auto",
221
260
  publishSlugSummaryAction: "Edit custom link",
222
261
  publishSlugReset: "Reset link",
223
262
  publishSlugSuggested: "Suggested link",
@@ -233,9 +272,9 @@ const labels: ComposeLabels = {
233
272
  threadLimitReached: "Threads can include up to 20 posts.",
234
273
  showMore: "Show more",
235
274
  showLess: "Show less",
236
- newThread: "New Thread",
237
- replyTitle: "Reply",
238
- editTitle: "Edit",
275
+ closeCompose: "Close compose",
276
+ editing: "Editing",
277
+ composeDialogLabel: "Compose",
239
278
  slashHint: "Type / for commands",
240
279
  tableControls: {
241
280
  toolbarLabel: "Table controls",
@@ -279,6 +318,35 @@ const collections: ComposeCollection[] = [
279
318
  { id: "col-2", title: "Movies", slug: "movies" },
280
319
  ];
281
320
 
321
+ /**
322
+ * Date and permalink hang off the post itself, not the publish panel. Both
323
+ * fields are open as soon as the panel is, so there is nothing further to
324
+ * expand. Idempotent.
325
+ */
326
+ async function openPostMeta(el: JantComposeDialog, index = 0) {
327
+ const pill = el.querySelectorAll<HTMLButtonElement>(
328
+ "[data-compose-post-meta-pill]",
329
+ )[index];
330
+ if (pill && pill.getAttribute("aria-expanded") !== "true") pill.click();
331
+ // The slug suggestion is fetched when the panel opens; let it settle.
332
+ await flushUpdates(el);
333
+ await flushUpdates(el);
334
+ }
335
+
336
+ /** The date or permalink as the post's own pill states it. */
337
+ function pillValue(
338
+ el: JantComposeDialog,
339
+ kind: "date" | "slug",
340
+ index = 0,
341
+ ): string | undefined {
342
+ const pill = el.querySelectorAll("[data-compose-post-meta-pill]")[index];
343
+ const selector =
344
+ kind === "date"
345
+ ? ".compose-post-meta-value:not(.compose-post-meta-value-slug)"
346
+ : ".compose-post-meta-value-slug";
347
+ return pill?.querySelector(selector)?.textContent?.trim();
348
+ }
349
+
282
350
  async function createElement(
283
351
  cols: ComposeCollection[] = collections,
284
352
  ): Promise<JantComposeDialog> {
@@ -331,8 +399,11 @@ describe("JantComposeDialog", () => {
331
399
  it("renders with collections and labels", async () => {
332
400
  const el = await createElement();
333
401
 
334
- // Header present
335
- expect(el.querySelector(".compose-dialog-header")).not.toBeNull();
402
+ // No title bar: the composer opens straight onto the post header row. A new
403
+ // post gets no marker either — an empty composer already says that much.
404
+ expect(el.querySelector(".compose-dialog-header")).toBeNull();
405
+ expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
406
+ expect(el.querySelector(".compose-post-badge")).toBeNull();
336
407
 
337
408
  // Format buttons present
338
409
  const segmentedItems = el.querySelectorAll(".compose-segmented-item");
@@ -350,7 +421,7 @@ describe("JantComposeDialog", () => {
350
421
  expect(postBtn.disabled).toBe(true);
351
422
  expect(
352
423
  requireElement(
353
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
424
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
354
425
  "expected publish settings toggle",
355
426
  ).disabled,
356
427
  ).toBe(false);
@@ -382,7 +453,6 @@ describe("JantComposeDialog", () => {
382
453
  ],
383
454
  },
384
455
  title: "",
385
- showTitle: false,
386
456
  selection: { from: 3, to: 3 },
387
457
  replyExpanded: false,
388
458
  },
@@ -490,7 +560,7 @@ describe("JantComposeDialog", () => {
490
560
  const el = await createElement();
491
561
 
492
562
  requireElement(
493
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
563
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
494
564
  "expected publish settings toggle",
495
565
  ).click();
496
566
  await el.updateComplete;
@@ -498,16 +568,39 @@ describe("JantComposeDialog", () => {
498
568
  expect(el.querySelector(".compose-publish-panel")).not.toBeNull();
499
569
  });
500
570
 
501
- it("updates the draft button label to match its current action", async () => {
571
+ it("offers saving separately from browsing once there is something to save", async () => {
502
572
  const el = await createElement();
573
+ await openPublishPanel(el);
574
+
575
+ // Nothing written yet, so there is only a place to go.
576
+ expect(sheetRowTitles(el)).toContain("Drafts");
577
+ expect(sheetRowTitles(el)).not.toContain("Save as draft");
578
+
579
+ const editor = requireElement(
580
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
581
+ "expected compose editor",
582
+ );
583
+ editor._bodyJson = {
584
+ type: "doc",
585
+ content: [
586
+ { type: "paragraph", content: [{ type: "text", text: "Hello world" }] },
587
+ ],
588
+ };
589
+ await editor.updateComplete;
590
+ await flushUpdates(el);
503
591
 
504
- const draftButton = requireElement(
505
- el.querySelector<HTMLButtonElement>(".compose-dialog-draft-btn"),
506
- "expected draft button",
592
+ // Now both: an action on this post, and the list. Neither label lies about
593
+ // what its row does.
594
+ const titles = sheetRowTitles(el);
595
+ expect(titles).toContain("Save as draft");
596
+ expect(titles).toContain("Drafts");
597
+ expect(titles.indexOf("Save as draft")).toBeLessThan(
598
+ titles.indexOf("Drafts"),
507
599
  );
508
- expect(draftButton.title).toBe("Drafts");
509
- expect(draftButton.getAttribute("aria-label")).toBe("Drafts");
600
+ });
510
601
 
602
+ it("saves straight to a draft from its own row, with no prompt in the way", async () => {
603
+ const el = await createElement();
511
604
  const editor = requireElement(
512
605
  el.querySelector<JantComposeEditor>("jant-compose-editor"),
513
606
  "expected compose editor",
@@ -519,34 +612,299 @@ describe("JantComposeDialog", () => {
519
612
  ],
520
613
  };
521
614
  await editor.updateComplete;
522
- await el.updateComplete;
615
+ await flushUpdates(el);
616
+
617
+ let detail: ComposeSubmitDetail | null = null;
618
+ el.addEventListener("jant:compose-submit-deferred", (event) => {
619
+ detail = (event as CustomEvent<ComposeSubmitDetail>).detail;
620
+ });
621
+
622
+ await openPublishPanel(el);
623
+ requireElement(
624
+ sheetRow(el, "Save as draft"),
625
+ "expected save-as-draft row",
626
+ ).click();
627
+ await flushUpdates(el);
628
+
629
+ expect(el._confirmPanelOpen).toBe(false);
630
+ expect(el._draftsPanelOpen).toBe(false);
631
+ expect(detail).not.toBeNull();
632
+ expect(detail!.status).toBe("draft");
633
+ });
634
+
635
+ it("opens options from its own button, not a chevron on Publish", async () => {
636
+ const el = await createElement();
637
+
638
+ // The split button is gone: Publish is one button, options is another.
639
+ expect(el.querySelector(".compose-publish-toggle")).toBeNull();
640
+
641
+ const trigger = requireElement(
642
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
643
+ "expected options trigger",
644
+ );
645
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
646
+ expect(el.querySelector(".compose-sheet")).toBeNull();
647
+
648
+ trigger.click();
649
+ await flushUpdates(el);
650
+
651
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
652
+ expect(el.querySelector(".compose-sheet")).not.toBeNull();
653
+ });
654
+
655
+ it("keeps date and permalink on the post, not in the publish panel", async () => {
656
+ const el = await createElement();
657
+
658
+ // They describe one post, so they hang off the post — not the publish
659
+ // panel, which speaks for the whole submission.
660
+ requireElement(
661
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
662
+ "expected options trigger",
663
+ ).click();
664
+ await flushUpdates(el);
665
+ expect(el.querySelector(".compose-publish-date-input")).toBeNull();
666
+ expect(el.querySelector(".compose-publish-slug-input")).toBeNull();
667
+
668
+ // The pill states both values while the panel is shut.
669
+ expect(pillValue(el, "date")).toBe("Now");
670
+ expect(pillValue(el, "slug")).toBe("Auto");
671
+
672
+ // Two settings is not a list to drill through: opening the pill opens both
673
+ // fields at once, with focus on the panel so Tab lands in them.
674
+ await openPostMeta(el);
675
+ expect(el.querySelector(".compose-publish-date-input")).not.toBeNull();
676
+ expect(el.querySelector(".compose-publish-slug-input")).not.toBeNull();
677
+ expect(el.querySelector("[data-compose-post-meta-panel]")).toBe(
678
+ document.activeElement,
679
+ );
680
+ });
681
+
682
+ it("pins the date panel to its pill instead of inside the scroller", async () => {
683
+ const el = await createElement();
684
+
685
+ // The pill lives in `.compose-scroll`, which clips and scrolls. An
686
+ // absolutely positioned panel is taller than that box on an empty post, so
687
+ // it lost its footer, and focusing it scrolled the format switcher off the
688
+ // top to bring it into view. Fixed positioning leaves the scroller behind,
689
+ // which makes placing the panel our job.
690
+ const focusCalls: Array<{
691
+ target: unknown;
692
+ options?: globalThis.FocusOptions;
693
+ }> = [];
694
+ const nativeFocus = globalThis.HTMLElement.prototype.focus;
695
+ globalThis.HTMLElement.prototype.focus = function (
696
+ this: globalThis.HTMLElement,
697
+ options?: globalThis.FocusOptions,
698
+ ) {
699
+ focusCalls.push({ target: this, options });
700
+ return nativeFocus.call(this, options);
701
+ };
702
+
703
+ try {
704
+ await openPostMeta(el);
705
+ } finally {
706
+ globalThis.HTMLElement.prototype.focus = nativeFocus;
707
+ }
708
+
709
+ const panel = requireElement(
710
+ el.querySelector<globalThis.HTMLElement>(
711
+ "[data-compose-post-meta-panel]",
712
+ ),
713
+ "expected post meta panel",
714
+ );
715
+
716
+ expect(focusCalls.find((call) => call.target === panel)?.options).toEqual({
717
+ preventScroll: true,
718
+ });
719
+
720
+ // Placed under the pill, and capped at the room the viewport actually has
721
+ // so a short window scrolls the panel rather than cropping it.
722
+ expect(panel.style.top).toBe("6px");
723
+ expect(panel.style.left).not.toBe("");
724
+ expect(
725
+ panel.style.getPropertyValue("--compose-post-meta-panel-max-height"),
726
+ ).toBe(`${globalThis.innerHeight - 12 - 6}px`);
727
+
728
+ // Inline coordinates only mean anything if the panel is out of the
729
+ // scroller — `position: absolute` here is the bug, not a style choice.
730
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
731
+ expect(css).toMatch(
732
+ /\.compose-post-meta-panel\s*\{\s*position:\s*fixed;[\s\S]*max-height:\s*var\(--compose-post-meta-panel-max-height,\s*none\);\s*overflow-y:\s*auto;/,
733
+ );
734
+ });
735
+
736
+ it("keeps the date panel on its pill when the composer scrolls", async () => {
737
+ const el = await createElement();
738
+ await openPostMeta(el);
739
+
740
+ const panel = requireElement(
741
+ el.querySelector<globalThis.HTMLElement>(
742
+ "[data-compose-post-meta-panel]",
743
+ ),
744
+ "expected post meta panel",
745
+ );
746
+ const pill = requireElement(
747
+ el.querySelector<globalThis.HTMLElement>("[data-compose-post-meta-pill]"),
748
+ "expected post meta pill",
749
+ );
750
+ pill.getBoundingClientRect = () =>
751
+ ({
752
+ top: 100,
753
+ bottom: 130,
754
+ left: 300,
755
+ right: 400,
756
+ width: 100,
757
+ height: 30,
758
+ }) as globalThis.DOMRect;
759
+
760
+ // A `scroll` event on the composer's own box never reaches `window` — it
761
+ // doesn't bubble — so a pinned panel would hang in place while the pill it
762
+ // belongs to slid away underneath it.
763
+ const scroller = requireElement(
764
+ el.querySelector(".compose-scroll"),
765
+ "expected compose scroller",
766
+ );
767
+ scroller.dispatchEvent(new globalThis.Event("scroll"));
768
+
769
+ expect(panel.style.top).toBe("136px");
770
+ expect(panel.style.left).toBe("400px");
771
+ });
772
+
773
+ it("opens the calendar from its own button, not the UA glyph", async () => {
774
+ const el = await createElement();
775
+ await openPostMeta(el);
776
+
777
+ const input = requireElement(
778
+ el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
779
+ "expected publish date input",
780
+ );
781
+ // Chrome only opens the picker from its ~13px indicator, which is hidden
782
+ // here — the button has to drive the same picker itself.
783
+ const showPicker = vi.fn();
784
+ (input as HTMLInputElement & { showPicker: () => void }).showPicker =
785
+ showPicker;
786
+
787
+ requireElement(
788
+ el.querySelector<HTMLButtonElement>("[data-compose-date-picker]"),
789
+ "expected date picker button",
790
+ ).click();
791
+ await flushUpdates(el);
792
+
793
+ expect(showPicker).toHaveBeenCalledTimes(1);
794
+ expect(input).toBe(document.activeElement);
795
+ });
796
+
797
+ it("keeps the date field usable when showPicker is unsupported", async () => {
798
+ const el = await createElement();
799
+ await openPostMeta(el);
800
+
801
+ const input = requireElement(
802
+ el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
803
+ "expected publish date input",
804
+ );
805
+ // Engines without `showPicker` must still get a focused, typable field
806
+ // rather than an uncaught TypeError from the button.
807
+ expect(
808
+ (input as HTMLInputElement & { showPicker?: () => void }).showPicker,
809
+ ).toBeUndefined();
810
+
811
+ expect(() =>
812
+ requireElement(
813
+ el.querySelector<HTMLButtonElement>("[data-compose-date-picker]"),
814
+ "expected date picker button",
815
+ ).click(),
816
+ ).not.toThrow();
817
+ await flushUpdates(el);
818
+
819
+ expect(input).toBe(document.activeElement);
820
+ });
523
821
 
524
- expect(draftButton.title).toBe("Save as draft");
525
- expect(draftButton.getAttribute("aria-label")).toBe("Save as draft");
822
+ it("closes the date panel from its Done button", async () => {
823
+ const el = await createElement();
824
+ await openPostMeta(el);
825
+
826
+ // Escape and an outside click both close it, but neither announces itself,
827
+ // and touch has no Escape key at all.
828
+ requireElement(
829
+ el.querySelector<HTMLButtonElement>("[data-compose-post-meta-done]"),
830
+ "expected Done button",
831
+ ).click();
832
+ await flushUpdates(el);
833
+
834
+ expect(el.querySelector("[data-compose-post-meta-panel]")).toBeNull();
835
+ expect(el.querySelector(".compose-publish-date-input")).toBeNull();
836
+ });
837
+
838
+ it("hands focus back to the post when the date panel closes", async () => {
839
+ const el = await createElement();
840
+ await openPostMeta(el);
841
+ expect(el.querySelector("[data-compose-post-meta-panel]")).toBe(
842
+ document.activeElement,
843
+ );
844
+
845
+ // The panel holds focus while it is open, so it would take focus out of the
846
+ // document with it on the way out. The pill is only visible on hover, focus
847
+ // or a non-default value — and on touch there is no hover — so losing focus
848
+ // to `<body>` hides the only way back in.
849
+ const editor = requireElement(
850
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
851
+ "expected editor",
852
+ );
853
+ const focusInput = vi.spyOn(editor, "focusInput");
854
+
855
+ requireElement(
856
+ el.querySelector<HTMLButtonElement>("[data-compose-post-meta-pill]"),
857
+ "expected post meta pill",
858
+ ).click();
859
+ await flushUpdates(el);
860
+
861
+ expect(el.querySelector("[data-compose-post-meta-panel]")).toBeNull();
862
+ expect(focusInput).toHaveBeenCalled();
526
863
  });
527
864
 
528
- it("shows the selected visibility hint in the publish settings panel", async () => {
865
+ it("explains every visibility option and checks the selected one", async () => {
529
866
  const el = await createElement();
530
867
 
531
868
  requireElement(
532
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
869
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
533
870
  "expected publish settings toggle",
534
871
  ).click();
535
872
  await el.updateComplete;
536
873
 
537
- // Default is public — only public hint shown
874
+ // Each row carries its own hint, so the options can be compared before
875
+ // one is picked — a single hint under a chip group could not do that.
538
876
  expect(el.textContent).toContain("Appears in Latest.");
539
- expect(el.textContent).not.toContain("Only visible when signed in.");
877
+ expect(el.textContent).toContain("Only visible when signed in.");
540
878
 
541
- // Switch to private — hint updates
542
- const chips = el.querySelectorAll<HTMLButtonElement>(
543
- ".compose-publish-chip[role='radio']",
879
+ const rows = el.querySelectorAll<HTMLButtonElement>(
880
+ ".compose-sheet-row[role='radio']",
544
881
  );
545
- chips[2]?.click();
546
- await el.updateComplete;
882
+ expect(rows).toHaveLength(3);
883
+ expect(rows[0].getAttribute("aria-checked")).toBe("true");
884
+ expect(rows[0].querySelector(".compose-sheet-check")).not.toBeNull();
885
+ expect(rows[2].querySelector(".compose-sheet-check")).toBeNull();
547
886
 
548
- expect(el.textContent).toContain("Only visible when signed in.");
549
- expect(el.textContent).not.toContain("Appears in Latest.");
887
+ rows[2].click();
888
+ await flushUpdates(el);
889
+
890
+ // Picking is the whole job of this panel, so it dismisses itself — no
891
+ // Done button to confirm a radio selection.
892
+ expect(el._visibility).toBe("private");
893
+ expect(el.querySelector(".compose-sheet")).toBeNull();
894
+ expect(el.querySelector(".compose-publish-done")).toBeNull();
895
+
896
+ // Reopening shows the new choice checked.
897
+ requireElement(
898
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
899
+ "expected publish settings toggle",
900
+ ).click();
901
+ await flushUpdates(el);
902
+ const updated = el.querySelectorAll<HTMLButtonElement>(
903
+ ".compose-sheet-row[role='radio']",
904
+ );
905
+ expect(updated[2].getAttribute("aria-checked")).toBe("true");
906
+ expect(updated[2].querySelector(".compose-sheet-check")).not.toBeNull();
907
+ expect(updated[0].querySelector(".compose-sheet-check")).toBeNull();
550
908
  });
551
909
 
552
910
  it("format switching updates active state", async () => {
@@ -630,7 +988,7 @@ describe("JantComposeDialog", () => {
630
988
  );
631
989
  }
632
990
 
633
- it("shows the format switcher instead of a title while editing", async () => {
991
+ it("shows the format switcher above the post while editing", async () => {
634
992
  vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
635
993
  cb(0);
636
994
  return 1;
@@ -641,12 +999,21 @@ describe("JantComposeDialog", () => {
641
999
  await el.openEdit("pst_123");
642
1000
  await flushUpdates(el);
643
1001
 
644
- expect(el.querySelector(".compose-segmented")).not.toBeNull();
645
- // The "Edit post" title is gone — the switcher takes the center slot.
646
- expect(el.querySelector(".compose-dialog-title")).toBeNull();
1002
+ // The switcher has exactly one home: above its own post. Editing says so
1003
+ // through the submit label and a marker in the header row, not a title bar.
1004
+ expect(el.querySelector(".compose-thread-post-header")).toBeTruthy();
1005
+ expect(el.querySelector(".compose-dialog-header")).toBeNull();
1006
+ expect(
1007
+ el
1008
+ .querySelector(".compose-thread-post-header .compose-post-badge")
1009
+ ?.textContent?.trim(),
1010
+ ).toBe(labels.editing);
1011
+ expect(el.querySelector(".compose-publish-main")?.textContent?.trim()).toBe(
1012
+ labels.update,
1013
+ );
647
1014
  });
648
1015
 
649
- it("shows a Reply title with the format selector above the post when replying", async () => {
1016
+ it("shows the format selector above the post when replying", async () => {
650
1017
  const el = await createElement();
651
1018
  await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
652
1019
  contentHtml: "<p>Parent</p>",
@@ -654,14 +1021,8 @@ describe("JantComposeDialog", () => {
654
1021
  });
655
1022
  await flushUpdates(el);
656
1023
 
657
- expect(el.querySelector(".compose-dialog-title")?.textContent?.trim()).toBe(
658
- "Reply",
659
- );
660
- // The header center no longer hosts the format selector...
661
- expect(
662
- el.querySelector(".compose-dialog-header-center .compose-segmented"),
663
- ).toBeNull();
664
- // ...it sits inline above the reply editor instead.
1024
+ expect(el.querySelector(".compose-dialog-header")).toBeNull();
1025
+ // The selector sits inline above the reply editor.
665
1026
  expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
666
1027
  expect(
667
1028
  el.querySelector(".compose-thread-layout.compose-reply-compose-layout"),
@@ -673,6 +1034,39 @@ describe("JantComposeDialog", () => {
673
1034
  expect(el.querySelector(".compose-publish-main")).not.toBeNull();
674
1035
  });
675
1036
 
1037
+ it("puts a reply's add-to-thread row on the rail with the other posts", async () => {
1038
+ const el = await createElement();
1039
+ await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
1040
+ contentHtml: "<p>Parent</p>",
1041
+ dateText: "Mar 14",
1042
+ });
1043
+ await flushUpdates(el);
1044
+
1045
+ // Inside the layout, not a sibling of it: out there it misses the rail's
1046
+ // indent and lands a rail's width left of everything above it.
1047
+ const layout = el.querySelector(
1048
+ ".compose-thread-layout.compose-reply-compose-layout",
1049
+ );
1050
+ const addRow = layout?.querySelector(":scope > .compose-thread-add-row");
1051
+ expect(addRow).not.toBeNull();
1052
+ expect(el.querySelector(".compose-add-thread-trigger")).toBeNull();
1053
+ // The dashed dot marks where the next post lands, as in thread compose.
1054
+ expect(addRow?.querySelector(".compose-thread-add-dot")).not.toBeNull();
1055
+ // ...and it stays the last row, so the rail still stops at the reply.
1056
+ expect(layout?.lastElementChild).toBe(addRow);
1057
+ });
1058
+
1059
+ it("keeps a single post's add-to-thread row off the rail", async () => {
1060
+ const el = await createElement();
1061
+ await flushUpdates(el);
1062
+
1063
+ // No rail to join, so no dot: the row's position says "next slot" alone.
1064
+ const trigger = el.querySelector(".compose-add-thread-trigger");
1065
+ expect(trigger).not.toBeNull();
1066
+ expect(el.querySelector(".compose-thread-add-dot")).toBeNull();
1067
+ expect(trigger?.querySelector(".compose-thread-add-btn")).not.toBeNull();
1068
+ });
1069
+
676
1070
  it("shows an Edit title with the format selector above the post when editing a reply", async () => {
677
1071
  vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
678
1072
  cb(0);
@@ -705,86 +1099,378 @@ describe("JantComposeDialog", () => {
705
1099
  await el.openEdit("pst_123");
706
1100
  await flushUpdates(el);
707
1101
 
708
- expect(el.querySelector(".compose-dialog-title")?.textContent?.trim()).toBe(
709
- "Edit",
1102
+ expect(el.querySelector(".compose-publish-main")?.textContent?.trim()).toBe(
1103
+ labels.update,
710
1104
  );
711
- expect(
712
- el.querySelector(".compose-dialog-header-center .compose-segmented"),
713
- ).toBeNull();
1105
+ expect(el.querySelector(".compose-dialog-header")).toBeNull();
714
1106
  expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
715
1107
  expect(el._collectionIds).toEqual([]);
716
1108
  expect(el.querySelector(".compose-collection-trigger")).toBeNull();
717
1109
  });
718
1110
 
719
- it("switches format from the inline selector when replying", async () => {
1111
+ it("keeps the format selector above the post in single-post mode", async () => {
720
1112
  const el = await createElement();
721
- await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
722
- contentHtml: "<p>Parent</p>",
723
- dateText: "Mar 14",
724
- });
725
1113
  await flushUpdates(el);
726
1114
 
1115
+ // One selector, one place — above its own post.
727
1116
  const items = el.querySelectorAll<HTMLButtonElement>(
728
1117
  ".compose-thread-post-header .compose-segmented-item",
729
1118
  );
730
- // note / link / quote
731
1119
  expect(items.length).toBe(3);
1120
+
732
1121
  items[1].click(); // link
733
1122
  await flushUpdates(el);
734
-
735
1123
  expect(el._format).toBe("link");
1124
+
1125
+ // A single post has no "which one", so no position marker.
1126
+ expect(el.querySelector(".compose-post-position")).toBeNull();
736
1127
  });
737
1128
 
738
- it("edit-mode format switch folds quote fields into the body", async () => {
739
- vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
740
- cb(0);
741
- return 1;
742
- });
743
- mockEditPost({
744
- format: "quote",
745
- quoteText: "Stay hungry",
746
- sourceName: "Jobs",
747
- body: null,
748
- });
1129
+ it("gives every thread post its own date and permalink control", async () => {
1130
+ const el = await createElement();
1131
+ el._threadItems = [
1132
+ { id: "thread-1", format: "note" },
1133
+ { id: "thread-2", format: "note" },
1134
+ ];
1135
+ await flushUpdates(el);
1136
+
1137
+ // Every post is separately addressable (`path_registry` has a row each),
1138
+ // so every post gets both controls — not just the root.
1139
+ const pills = el.querySelectorAll("[data-compose-post-meta-pill]");
1140
+ expect(pills).toHaveLength(2);
1141
+ for (const pill of pills) {
1142
+ expect(
1143
+ pill.querySelector(".compose-post-meta-value-slug"),
1144
+ ).not.toBeNull();
1145
+ }
1146
+ });
749
1147
 
1148
+ it("sends a reply's own permalink and blocks an invalid one", async () => {
750
1149
  const el = await createElement();
751
- await el.openEdit("pst_123");
1150
+ el._threadItems = [
1151
+ { id: "thread-1", format: "note" },
1152
+ { id: "thread-2", format: "note", slug: "part-two" },
1153
+ ];
752
1154
  await flushUpdates(el);
753
1155
 
754
- const editor = requireElement(
755
- el.querySelector<JantComposeEditor>("jant-compose-editor"),
756
- "expected compose editor",
1156
+ const build = (
1157
+ el as unknown as {
1158
+ _buildEditorPostDetail: (
1159
+ editor: JantComposeEditor,
1160
+ format: string,
1161
+ index: number,
1162
+ status: string,
1163
+ ) => ComposeSubmitDetail;
1164
+ }
1165
+ )._buildEditorPostDetail;
1166
+ const editors = el.querySelectorAll<JantComposeEditor>(
1167
+ "jant-compose-editor",
757
1168
  );
758
- expect(editor._quoteText).toBe("Stay hungry");
759
1169
 
760
- // Click the Note segmented button.
761
- el.querySelectorAll<HTMLButtonElement>(
762
- ".compose-segmented-item",
763
- )[0].click();
764
- await flushUpdates(el);
1170
+ expect(build.call(el, editors[1], "note", 1, "published").slug).toBe(
1171
+ "part-two",
1172
+ );
1173
+ expect(build.call(el, editors[0], "note", 0, "published").slug).toBe(
1174
+ undefined,
1175
+ );
765
1176
 
766
- expect(el._format).toBe("note");
767
- expect(editor._quoteText).toBe("");
768
- expect(editor._quoteAuthor).toBe("");
769
- expect(editor._bodyJson?.content?.[0]?.type).toBe("blockquote");
1177
+ // A reply's bad slug blocks publish just like the root's would.
1178
+ el._threadItems = [
1179
+ { id: "thread-1", format: "note" },
1180
+ { id: "thread-2", format: "note", slug: "not a slug!" },
1181
+ ];
1182
+ await flushUpdates(el);
1183
+ expect(
1184
+ (el as unknown as { _canPublish: () => boolean })._canPublish(),
1185
+ ).toBe(false);
770
1186
  });
771
1187
 
772
- it("edit-mode format switch marks the post as having unsaved changes", async () => {
773
- vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
774
- cb(0);
775
- return 1;
776
- });
777
- mockEditPost({ format: "note", title: "Hello", body: null });
778
-
1188
+ it("sends a reply's own date and leaves the rest for the server to inherit", async () => {
779
1189
  const el = await createElement();
780
- await el.openEdit("pst_123");
1190
+ el._threadItems = [
1191
+ { id: "thread-1", format: "note" },
1192
+ { id: "thread-2", format: "note", publishedAtInput: "2024-03-09" },
1193
+ { id: "thread-3", format: "note" },
1194
+ ];
781
1195
  await flushUpdates(el);
782
1196
 
783
- const hasUnsaved = (el as unknown as { _hasUnsavedChanges(): boolean })
784
- ._hasUnsavedChanges;
785
- expect(hasUnsaved.call(el)).toBe(false);
786
-
787
- el.querySelectorAll<HTMLButtonElement>(
1197
+ const detail = (
1198
+ el as unknown as {
1199
+ _buildEditorPostDetail: (
1200
+ editor: JantComposeEditor,
1201
+ format: string,
1202
+ index: number,
1203
+ status: string,
1204
+ ) => ComposeSubmitDetail;
1205
+ }
1206
+ )._buildEditorPostDetail;
1207
+ const editors = el.querySelectorAll<JantComposeEditor>(
1208
+ "jant-compose-editor",
1209
+ );
1210
+
1211
+ const second = detail.call(el, editors[1], "note", 1, "published");
1212
+ const third = detail.call(el, editors[2], "note", 2, "published");
1213
+
1214
+ expect(second.publishedAt).toBeGreaterThan(0);
1215
+ // Undefined, not "now" — the server fills it from the root.
1216
+ expect(third.publishedAt).toBeUndefined();
1217
+ });
1218
+
1219
+ it("blocks publishing when any thread post has a future date", async () => {
1220
+ const el = await createElement();
1221
+ const future = new Date(Date.now() + 86400000 * 3)
1222
+ .toISOString()
1223
+ .slice(0, 10);
1224
+ el._threadItems = [
1225
+ { id: "thread-1", format: "note" },
1226
+ { id: "thread-2", format: "note", publishedAtInput: future },
1227
+ ];
1228
+ await flushUpdates(el);
1229
+
1230
+ expect(
1231
+ (el as unknown as { _canPublish: () => boolean })._canPublish(),
1232
+ ).toBe(false);
1233
+ });
1234
+
1235
+ /**
1236
+ * Publish reads each row's own answer, so a row that has been removed stops
1237
+ * being asked. These used to fail: the button was computed during render from
1238
+ * the editors still in the DOM — including the one that render was about to
1239
+ * remove.
1240
+ */
1241
+ describe("publish reflects the rows that are left", () => {
1242
+ const publishButton = (el: JantComposeDialog) =>
1243
+ requireElement(
1244
+ el.querySelector<HTMLButtonElement>(".compose-publish-main"),
1245
+ "expected publish button",
1246
+ );
1247
+
1248
+ async function typeInto(
1249
+ el: JantComposeDialog,
1250
+ index: number,
1251
+ text: string,
1252
+ ) {
1253
+ const editor = el.querySelectorAll<JantComposeEditor>(
1254
+ "jant-compose-editor",
1255
+ )[index];
1256
+ requireEditor(
1257
+ requireElement(editor, `expected editor ${index}`),
1258
+ ).commands.insertContent(text);
1259
+ await flushUpdates(el);
1260
+ }
1261
+
1262
+ async function addPost(el: JantComposeDialog) {
1263
+ requireElement(
1264
+ el.querySelector<HTMLButtonElement>(".compose-thread-add-btn"),
1265
+ "expected add-to-thread button",
1266
+ ).click();
1267
+ await flushUpdates(el);
1268
+ await flushUpdates(el);
1269
+ }
1270
+
1271
+ async function removePost(el: JantComposeDialog, index: number) {
1272
+ requireElement(
1273
+ el.querySelectorAll<HTMLButtonElement>(".compose-thread-post-remove")[
1274
+ index
1275
+ ],
1276
+ `expected remove button ${index}`,
1277
+ ).click();
1278
+ await flushUpdates(el);
1279
+ await flushUpdates(el);
1280
+ }
1281
+
1282
+ it("re-enables publish when the empty last post is removed", async () => {
1283
+ const el = await createElement();
1284
+ await typeInto(el, 0, "one");
1285
+ await addPost(el);
1286
+ await typeInto(el, 1, "two");
1287
+ await addPost(el);
1288
+
1289
+ expect(publishButton(el).disabled).toBe(true);
1290
+
1291
+ await removePost(el, 2);
1292
+
1293
+ expect(publishButton(el).disabled).toBe(false);
1294
+ });
1295
+
1296
+ it("re-enables publish when the emptied post is the first of two", async () => {
1297
+ const el = await createElement();
1298
+ await typeInto(el, 0, "one");
1299
+ await addPost(el);
1300
+ await typeInto(el, 1, "two");
1301
+
1302
+ requireEditor(
1303
+ el.querySelectorAll<JantComposeEditor>("jant-compose-editor")[0],
1304
+ ).commands.clearContent();
1305
+ await flushUpdates(el);
1306
+ expect(publishButton(el).disabled).toBe(true);
1307
+
1308
+ await removePost(el, 0);
1309
+
1310
+ expect(publishButton(el).disabled).toBe(false);
1311
+ });
1312
+
1313
+ it("keeps each post's own text when the middle post is removed", async () => {
1314
+ const el = await createElement();
1315
+ await typeInto(el, 0, "AAA");
1316
+ await addPost(el);
1317
+ await typeInto(el, 1, "BBB");
1318
+ await addPost(el);
1319
+ await typeInto(el, 2, "CCC");
1320
+
1321
+ await removePost(el, 1);
1322
+
1323
+ const text = Array.from(
1324
+ el.querySelectorAll<JantComposeEditor>("jant-compose-editor"),
1325
+ ).map((editor) => requireEditor(editor).getText());
1326
+ expect(text).toEqual(["AAA", "CCC"]);
1327
+ });
1328
+ });
1329
+
1330
+ it("numbers each post in a thread", async () => {
1331
+ const el = await createElement();
1332
+ el._threadItems = [
1333
+ { id: "thread-1", format: "note" },
1334
+ { id: "thread-2", format: "note" },
1335
+ ];
1336
+ await flushUpdates(el);
1337
+
1338
+ const positions = () =>
1339
+ Array.from(el.querySelectorAll(".compose-post-position")).map((n) =>
1340
+ n.textContent?.trim(),
1341
+ );
1342
+ expect(positions()).toEqual(["1/2", "2/2"]);
1343
+
1344
+ (el as unknown as { _addThreadItem: () => void })._addThreadItem();
1345
+ await flushUpdates(el);
1346
+
1347
+ // The total re-renders on every post, not just the new one.
1348
+ expect(positions()).toEqual(["1/3", "2/3", "3/3"]);
1349
+ });
1350
+
1351
+ const positionsIn = (el: JantComposeDialog) =>
1352
+ Array.from(el.querySelectorAll(".compose-post-position")).map((n) =>
1353
+ n.textContent?.trim(),
1354
+ );
1355
+
1356
+ it("numbers a reply from the end of the thread it continues", async () => {
1357
+ // The parent is the second post of a thread, so the reply is the third —
1358
+ // not the first, and not the second.
1359
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
1360
+ new Response(JSON.stringify({ id: "pst_parent", threadPosition: 2 }), {
1361
+ headers: { "Content-Type": "application/json" },
1362
+ }),
1363
+ );
1364
+
1365
+ const el = await createElement();
1366
+ await el.openReply("pst_parent", {
1367
+ contentHtml: "<p>Parent</p>",
1368
+ dateText: "Mar 14",
1369
+ });
1370
+ await flushUpdates(el);
1371
+ await flushUpdates(el);
1372
+
1373
+ expect(positionsIn(el)).toEqual(["3/3"]);
1374
+
1375
+ (el as unknown as { _addThreadItem: () => void })._addThreadItem();
1376
+ await flushUpdates(el);
1377
+
1378
+ expect(positionsIn(el)).toEqual(["3/4", "4/4"]);
1379
+ });
1380
+
1381
+ it("shows no number for a reply until the parent's position is known", async () => {
1382
+ // Counting from 1 and then correcting to 3 is worse than arriving late, so
1383
+ // the marker stays away while the lookup is in flight — and stays away for
1384
+ // good if it fails.
1385
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("offline"));
1386
+
1387
+ const el = await createElement();
1388
+ await el.openReply("pst_parent", {
1389
+ contentHtml: "<p>Parent</p>",
1390
+ dateText: "Mar 14",
1391
+ });
1392
+ await flushUpdates(el);
1393
+ await flushUpdates(el);
1394
+
1395
+ expect(positionsIn(el)).toEqual([]);
1396
+ });
1397
+
1398
+ it("leaves a lone new post unnumbered — there is no chain to place it in", async () => {
1399
+ const el = await createElement();
1400
+ await flushUpdates(el);
1401
+
1402
+ expect(el.querySelector(".compose-post-position")).toBeNull();
1403
+ });
1404
+
1405
+ it("switches format from the inline selector when replying", async () => {
1406
+ const el = await createElement();
1407
+ await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
1408
+ contentHtml: "<p>Parent</p>",
1409
+ dateText: "Mar 14",
1410
+ });
1411
+ await flushUpdates(el);
1412
+
1413
+ const items = el.querySelectorAll<HTMLButtonElement>(
1414
+ ".compose-thread-post-header .compose-segmented-item",
1415
+ );
1416
+ // note / link / quote
1417
+ expect(items.length).toBe(3);
1418
+ items[1].click(); // link
1419
+ await flushUpdates(el);
1420
+
1421
+ expect(el._format).toBe("link");
1422
+ });
1423
+
1424
+ it("edit-mode format switch folds quote fields into the body", async () => {
1425
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
1426
+ cb(0);
1427
+ return 1;
1428
+ });
1429
+ mockEditPost({
1430
+ format: "quote",
1431
+ quoteText: "Stay hungry",
1432
+ sourceName: "Jobs",
1433
+ body: null,
1434
+ });
1435
+
1436
+ const el = await createElement();
1437
+ await el.openEdit("pst_123");
1438
+ await flushUpdates(el);
1439
+
1440
+ const editor = requireElement(
1441
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
1442
+ "expected compose editor",
1443
+ );
1444
+ expect(editor._quoteText).toBe("Stay hungry");
1445
+
1446
+ // Click the Note segmented button.
1447
+ el.querySelectorAll<HTMLButtonElement>(
1448
+ ".compose-segmented-item",
1449
+ )[0].click();
1450
+ await flushUpdates(el);
1451
+
1452
+ expect(el._format).toBe("note");
1453
+ expect(editor._quoteText).toBe("");
1454
+ expect(editor._quoteAuthor).toBe("");
1455
+ expect(editor._bodyJson?.content?.[0]?.type).toBe("blockquote");
1456
+ });
1457
+
1458
+ it("edit-mode format switch marks the post as having unsaved changes", async () => {
1459
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
1460
+ cb(0);
1461
+ return 1;
1462
+ });
1463
+ mockEditPost({ format: "note", title: "Hello", body: null });
1464
+
1465
+ const el = await createElement();
1466
+ await el.openEdit("pst_123");
1467
+ await flushUpdates(el);
1468
+
1469
+ const hasUnsaved = (el as unknown as { _hasUnsavedChanges(): boolean })
1470
+ ._hasUnsavedChanges;
1471
+ expect(hasUnsaved.call(el)).toBe(false);
1472
+
1473
+ el.querySelectorAll<HTMLButtonElement>(
788
1474
  ".compose-segmented-item",
789
1475
  )[2].click();
790
1476
  await flushUpdates(el);
@@ -1040,13 +1726,13 @@ describe("JantComposeDialog", () => {
1040
1726
  await editor.updateComplete;
1041
1727
 
1042
1728
  requireElement(
1043
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
1729
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1044
1730
  "expected publish settings toggle",
1045
1731
  ).click();
1046
1732
  await el.updateComplete;
1047
1733
 
1048
1734
  const options = el.querySelectorAll<HTMLButtonElement>(
1049
- ".compose-publish-chip[role='radio']",
1735
+ ".compose-sheet-row[role='radio']",
1050
1736
  );
1051
1737
  expect(options).toHaveLength(3);
1052
1738
  options[1]?.click();
@@ -1100,11 +1786,12 @@ describe("JantComposeDialog", () => {
1100
1786
  await editor.updateComplete;
1101
1787
 
1102
1788
  requireElement(
1103
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
1789
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1104
1790
  "expected publish settings toggle",
1105
1791
  ).click();
1106
1792
  await el.updateComplete;
1107
1793
 
1794
+ await openPostMeta(el);
1108
1795
  const publishedAtInput = requireElement(
1109
1796
  el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
1110
1797
  "expected publish date input",
@@ -1145,16 +1832,17 @@ describe("JantComposeDialog", () => {
1145
1832
  }
1146
1833
  });
1147
1834
 
1148
- it("shows a publish date summary near the submit controls after the date changes", async () => {
1835
+ it("keeps the chosen publish date readable on the post's pill", async () => {
1149
1836
  const el = await createElement();
1150
1837
 
1151
1838
  const publishToggle = requireElement(
1152
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
1839
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1153
1840
  "expected publish settings toggle",
1154
1841
  );
1155
1842
  publishToggle.click();
1156
1843
  await el.updateComplete;
1157
1844
 
1845
+ await openPostMeta(el);
1158
1846
  const publishedAtInput = requireElement(
1159
1847
  el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
1160
1848
  "expected publish date input",
@@ -1163,31 +1851,25 @@ describe("JantComposeDialog", () => {
1163
1851
  publishedAtInput.dispatchEvent(new Event("input", { bubbles: true }));
1164
1852
  await el.updateComplete;
1165
1853
 
1166
- publishToggle.click();
1167
- await el.updateComplete;
1168
-
1169
- const summary = requireElement(
1170
- el.querySelector<HTMLButtonElement>(
1171
- "[data-compose-publish-date-summary]",
1172
- ),
1173
- "expected publish date summary",
1174
- );
1175
- expect(summary.dataset.publishDate).toBe("2024-01-15");
1176
- expect(summary.textContent).toContain("2024");
1177
-
1178
- summary.click();
1179
- await el.updateComplete;
1854
+ // Close the panel: the chosen date stays readable on the pill.
1855
+ requireElement(
1856
+ el.querySelector<HTMLButtonElement>("[data-compose-post-meta-pill]"),
1857
+ "expected post meta pill",
1858
+ ).click();
1180
1859
  await flushUpdates(el);
1860
+ expect(el.querySelector(".compose-publish-date-input")).toBeNull();
1861
+ expect(pillValue(el, "date")).toContain("2024");
1181
1862
 
1863
+ await openPostMeta(el);
1182
1864
  expect(
1183
1865
  requireElement(
1184
1866
  el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
1185
1867
  "expected publish date input after reopening publish settings",
1186
- ),
1187
- ).toBe(document.activeElement);
1868
+ ).value,
1869
+ ).toBe("2024-01-15");
1188
1870
  });
1189
1871
 
1190
- it("does not show a publish date summary for an unchanged date while editing a post", async () => {
1872
+ it("shows the existing publish date on the post pill while editing a post", async () => {
1191
1873
  const el = await createElement();
1192
1874
  (
1193
1875
  el as unknown as {
@@ -1213,10 +1895,12 @@ describe("JantComposeDialog", () => {
1213
1895
  el.requestUpdate();
1214
1896
  await el.updateComplete;
1215
1897
 
1216
- expect(el.querySelector("[data-compose-publish-date-summary]")).toBeNull();
1898
+ await openPostMeta(el);
1899
+
1900
+ expect(pillValue(el, "date")).toContain("2024");
1217
1901
  });
1218
1902
 
1219
- it("shows a publish now summary after clearing the publish date while editing a post", async () => {
1903
+ it("shows Now on the post date control after clearing the date while editing", async () => {
1220
1904
  const el = await createElement();
1221
1905
  (
1222
1906
  el as unknown as {
@@ -1242,15 +1926,12 @@ describe("JantComposeDialog", () => {
1242
1926
  el.requestUpdate();
1243
1927
  await el.updateComplete;
1244
1928
 
1245
- expect(
1246
- requireElement(
1247
- el.querySelector<HTMLElement>("[data-compose-publish-date-summary]"),
1248
- "expected publish now summary",
1249
- ).textContent,
1250
- ).toContain("Publish now");
1929
+ await openPostMeta(el);
1930
+
1931
+ expect(pillValue(el, "date")).toBe("Now");
1251
1932
  });
1252
1933
 
1253
- it("shows a custom link summary near the submit controls after the link changes", async () => {
1934
+ it("keeps the custom link readable on the post's pill", async () => {
1254
1935
  mockSlugApi((url) => {
1255
1936
  if (url.searchParams.get("mode") === "suggest") {
1256
1937
  return { body: { slug: "configured-post" } };
@@ -1263,7 +1944,7 @@ describe("JantComposeDialog", () => {
1263
1944
 
1264
1945
  const el = await createElement();
1265
1946
  const publishToggle = requireElement(
1266
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
1947
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1267
1948
  "expected publish settings toggle",
1268
1949
  );
1269
1950
 
@@ -1271,6 +1952,7 @@ describe("JantComposeDialog", () => {
1271
1952
  await el.updateComplete;
1272
1953
  await flushUpdates(el);
1273
1954
 
1955
+ await openPostMeta(el);
1274
1956
  const slugInput = requireElement(
1275
1957
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
1276
1958
  "expected custom link input",
@@ -1280,31 +1962,25 @@ describe("JantComposeDialog", () => {
1280
1962
  await new Promise((resolve) => setTimeout(resolve, 300));
1281
1963
  await el.updateComplete;
1282
1964
 
1283
- publishToggle.click();
1284
- await el.updateComplete;
1285
-
1286
- const summary = requireElement(
1287
- el.querySelector<HTMLButtonElement>(
1288
- "[data-compose-publish-slug-summary]",
1289
- ),
1290
- "expected custom link summary",
1291
- );
1292
- expect(summary.dataset.publishSlug).toBe("reading-notes");
1293
- expect(summary.textContent).toContain("/reading-notes");
1294
-
1295
- summary.click();
1296
- await el.updateComplete;
1965
+ // Close the panel: the custom link stays readable on the pill.
1966
+ requireElement(
1967
+ el.querySelector<HTMLButtonElement>("[data-compose-post-meta-pill]"),
1968
+ "expected post meta pill",
1969
+ ).click();
1297
1970
  await flushUpdates(el);
1971
+ expect(el.querySelector(".compose-publish-slug-input")).toBeNull();
1972
+ expect(pillValue(el, "slug")).toBe("/reading-notes");
1298
1973
 
1974
+ await openPostMeta(el);
1299
1975
  expect(
1300
1976
  requireElement(
1301
1977
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
1302
1978
  "expected custom link input after reopening publish settings",
1303
- ),
1304
- ).toBe(document.activeElement);
1979
+ ).value,
1980
+ ).toBe("reading-notes");
1305
1981
  });
1306
1982
 
1307
- it("does not show a custom link summary for an unchanged link while editing a post", async () => {
1983
+ it("shows the existing custom link on the post pill while editing a post", async () => {
1308
1984
  const el = await createElement();
1309
1985
  (
1310
1986
  el as unknown as {
@@ -1330,10 +2006,12 @@ describe("JantComposeDialog", () => {
1330
2006
  el.requestUpdate();
1331
2007
  await el.updateComplete;
1332
2008
 
1333
- expect(el.querySelector("[data-compose-publish-slug-summary]")).toBeNull();
2009
+ await openPostMeta(el);
2010
+
2011
+ expect(pillValue(el, "slug")).toBe("/reading-notes");
1334
2012
  });
1335
2013
 
1336
- it("shows an auto link summary after clearing the custom link while editing a post", async () => {
2014
+ it("shows Auto on the post permalink pill after clearing the link while editing", async () => {
1337
2015
  const el = await createElement();
1338
2016
  (
1339
2017
  el as unknown as {
@@ -1359,12 +2037,9 @@ describe("JantComposeDialog", () => {
1359
2037
  el.requestUpdate();
1360
2038
  await el.updateComplete;
1361
2039
 
1362
- expect(
1363
- requireElement(
1364
- el.querySelector<HTMLElement>("[data-compose-publish-slug-summary]"),
1365
- "expected auto link summary",
1366
- ).textContent,
1367
- ).toContain("Auto link");
2040
+ await openPostMeta(el);
2041
+
2042
+ expect(pillValue(el, "slug")).toBe("Auto");
1368
2043
  });
1369
2044
 
1370
2045
  it("blocks publishing with a future publish date", async () => {
@@ -1389,11 +2064,12 @@ describe("JantComposeDialog", () => {
1389
2064
  await editor.updateComplete;
1390
2065
 
1391
2066
  requireElement(
1392
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2067
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1393
2068
  "expected publish settings toggle",
1394
2069
  ).click();
1395
2070
  await el.updateComplete;
1396
2071
 
2072
+ await openPostMeta(el);
1397
2073
  const publishedAtInput = requireElement(
1398
2074
  el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
1399
2075
  "expected publish date input",
@@ -1420,13 +2096,13 @@ describe("JantComposeDialog", () => {
1420
2096
  const el = await createElement();
1421
2097
 
1422
2098
  requireElement(
1423
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2099
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1424
2100
  "expected publish settings toggle",
1425
2101
  ).click();
1426
2102
  await el.updateComplete;
1427
2103
 
1428
2104
  const options = el.querySelectorAll<HTMLButtonElement>(
1429
- ".compose-publish-chip[role='radio']",
2105
+ ".compose-sheet-row[role='radio']",
1430
2106
  );
1431
2107
  expect(options).toHaveLength(3);
1432
2108
  options[2]?.click();
@@ -1444,13 +2120,13 @@ describe("JantComposeDialog", () => {
1444
2120
  const el = await createElement();
1445
2121
 
1446
2122
  requireElement(
1447
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2123
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1448
2124
  "expected publish settings toggle",
1449
2125
  ).click();
1450
2126
  await el.updateComplete;
1451
2127
 
1452
2128
  const options = el.querySelectorAll<HTMLButtonElement>(
1453
- ".compose-publish-chip[role='radio']",
2129
+ ".compose-sheet-row[role='radio']",
1454
2130
  );
1455
2131
  expect(options).toHaveLength(3);
1456
2132
  options[1]?.click();
@@ -1464,6 +2140,135 @@ describe("JantComposeDialog", () => {
1464
2140
  ).toBe("Post hidden");
1465
2141
  });
1466
2142
 
2143
+ it("flips visibility from the Hide from Latest shortcut", async () => {
2144
+ const el = await createElement();
2145
+
2146
+ const toggle = requireElement(
2147
+ el.querySelector<HTMLInputElement>(
2148
+ ".compose-quick-actions-row .compose-publish-quick-toggle-input",
2149
+ ),
2150
+ "expected hide-from-latest shortcut",
2151
+ );
2152
+ expect(toggle.checked).toBe(false);
2153
+
2154
+ toggle.checked = true;
2155
+ toggle.dispatchEvent(new globalThis.Event("change"));
2156
+ await el.updateComplete;
2157
+ expect(el._visibility).toBe("latest_hidden");
2158
+
2159
+ toggle.checked = false;
2160
+ toggle.dispatchEvent(new globalThis.Event("change"));
2161
+ await el.updateComplete;
2162
+ expect(el._visibility).toBe("public");
2163
+ });
2164
+
2165
+ it("drops the Hide from Latest shortcut once the post is private", async () => {
2166
+ const el = await createElement();
2167
+
2168
+ requireElement(
2169
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
2170
+ "expected publish settings toggle",
2171
+ ).click();
2172
+ await el.updateComplete;
2173
+
2174
+ // A checkbox cannot speak for a third state, so it steps aside.
2175
+ el.querySelectorAll<HTMLButtonElement>(
2176
+ ".compose-sheet-row[role='radio']",
2177
+ )[2]?.click();
2178
+ await el.updateComplete;
2179
+
2180
+ expect(el._visibility).toBe("private");
2181
+ expect(el.querySelector(".compose-quick-actions-row")).toBeNull();
2182
+ });
2183
+
2184
+ it("offers the quiet reply shortcut on a reply, in place of visibility", async () => {
2185
+ const el = await createElement();
2186
+
2187
+ await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
2188
+ contentHtml: "<p>Parent</p>",
2189
+ dateText: "Mar 14",
2190
+ });
2191
+ await flushUpdates(el);
2192
+
2193
+ const toggles = el.querySelectorAll<HTMLInputElement>(
2194
+ ".compose-quick-actions-row .compose-publish-quick-toggle-input",
2195
+ );
2196
+ // A reply inherits the root's visibility, so only quiet reply is offered.
2197
+ expect(toggles).toHaveLength(1);
2198
+
2199
+ const toggle = requireElement(
2200
+ toggles.item(0),
2201
+ "expected quiet reply shortcut",
2202
+ );
2203
+ toggle.checked = true;
2204
+ toggle.dispatchEvent(new globalThis.Event("change"));
2205
+ await el.updateComplete;
2206
+
2207
+ expect(el._quietReply).toBe(true);
2208
+ expect(
2209
+ requireElement(
2210
+ el.querySelector<HTMLButtonElement>(".compose-publish-main"),
2211
+ "expected publish button",
2212
+ ).textContent?.trim(),
2213
+ ).toBe("Reply quietly");
2214
+ });
2215
+
2216
+ it("drops the quiet reply switch when editing an existing reply", async () => {
2217
+ const parentId = "019ce8ce-d6d8-7fda-a5df-c2da2bef5ade";
2218
+ vi.spyOn(globalThis, "fetch").mockImplementation((input) => {
2219
+ const url = String(input);
2220
+ const json = url.includes(parentId)
2221
+ ? { id: parentId, bodyHtml: "<p>Parent</p>", format: "note" }
2222
+ : {
2223
+ id: "pst_123",
2224
+ format: "note",
2225
+ title: null,
2226
+ body: null,
2227
+ replyToId: parentId,
2228
+ };
2229
+ return Promise.resolve(
2230
+ new Response(JSON.stringify(json), {
2231
+ headers: { "Content-Type": "application/json" },
2232
+ }),
2233
+ );
2234
+ });
2235
+
2236
+ const el = await createElement();
2237
+ await el.openEdit("pst_123");
2238
+ await flushUpdates(el);
2239
+
2240
+ // Saving an edit goes through the update path, which has no create-time
2241
+ // thread bump to skip — so the switch is gone rather than dead.
2242
+ expect(el.querySelector(".compose-quick-actions-row")).toBeNull();
2243
+
2244
+ el._showPublishPanel = true;
2245
+ await flushUpdates(el);
2246
+ expect(
2247
+ Array.from(el.querySelectorAll(".compose-sheet-title")).some(
2248
+ (row) => row.textContent?.trim() === labels.quietReplyLabel,
2249
+ ),
2250
+ ).toBe(false);
2251
+ });
2252
+
2253
+ it("keeps the quiet reply switch on a thread draft, which saves by recreating", async () => {
2254
+ const el = await createElement();
2255
+ await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
2256
+ contentHtml: "<p>Parent</p>",
2257
+ dateText: "Mar 14",
2258
+ });
2259
+ (el as unknown as { _addThreadItem: () => void })._addThreadItem();
2260
+ // Re-editing a saved thread draft deletes and recreates every post, so the
2261
+ // create-time flag still lands.
2262
+ el._draftSourceId = "pst_draft";
2263
+ await flushUpdates(el);
2264
+
2265
+ expect(
2266
+ el.querySelectorAll(
2267
+ ".compose-quick-actions-row .compose-publish-quick-toggle-input",
2268
+ ),
2269
+ ).toHaveLength(1);
2270
+ });
2271
+
1467
2272
  it("opens a new post with the requested collection and keeps the last visibility until refresh", async () => {
1468
2273
  const el = await createElement();
1469
2274
 
@@ -1474,13 +2279,13 @@ describe("JantComposeDialog", () => {
1474
2279
  expect(el._visibility).toBe("public");
1475
2280
 
1476
2281
  requireElement(
1477
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2282
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1478
2283
  "expected publish settings toggle",
1479
2284
  ).click();
1480
2285
  await el.updateComplete;
1481
2286
 
1482
2287
  const options = el.querySelectorAll<HTMLButtonElement>(
1483
- ".compose-publish-chip[role='radio']",
2288
+ ".compose-sheet-row[role='radio']",
1484
2289
  );
1485
2290
  options[1]?.click();
1486
2291
  await el.updateComplete;
@@ -1490,23 +2295,264 @@ describe("JantComposeDialog", () => {
1490
2295
  el.reset();
1491
2296
  await el.updateComplete;
1492
2297
 
1493
- expect(el._visibility).toBe("latest_hidden");
2298
+ expect(el._visibility).toBe("latest_hidden");
2299
+
2300
+ await el.openNew({ collectionId: "col-1", restoreDraft: false });
2301
+ await el.updateComplete;
2302
+
2303
+ expect(el._collectionIds).toEqual(["col-1"]);
2304
+ expect(el._visibility).toBe("latest_hidden");
2305
+ });
2306
+
2307
+ it("prepends the requested collection even when a local draft is restored", async () => {
2308
+ const el = await createElement();
2309
+
2310
+ globalThis.localStorage.setItem(
2311
+ "jant:compose-draft",
2312
+ JSON.stringify({
2313
+ format: "note",
2314
+ title: "",
2315
+ bodyJson: {
2316
+ type: "doc",
2317
+ content: [
2318
+ {
2319
+ type: "paragraph",
2320
+ content: [{ type: "text", text: "Draft body" }],
2321
+ },
2322
+ ],
2323
+ },
2324
+ url: "",
2325
+ quoteText: "",
2326
+ quoteAuthor: "",
2327
+ slug: "",
2328
+ visibility: "public",
2329
+ rating: 0,
2330
+ showRating: false,
2331
+ collectionIds: ["col-1"],
2332
+ attachedTexts: [],
2333
+ attachmentOrder: [],
2334
+ savedAt: Date.now(),
2335
+ }),
2336
+ );
2337
+
2338
+ await el.openNew({ collectionId: "col-2" });
2339
+ await flushUpdates(el);
2340
+
2341
+ expect(el._collectionIds).toEqual(["col-2", "col-1"]);
2342
+ });
2343
+
2344
+ function seedDraftWithMedia() {
2345
+ globalThis.localStorage.setItem(
2346
+ "jant:compose-draft",
2347
+ JSON.stringify({
2348
+ format: "note",
2349
+ title: "",
2350
+ bodyJson: {
2351
+ type: "doc",
2352
+ content: [
2353
+ {
2354
+ type: "paragraph",
2355
+ content: [{ type: "text", text: "Draft with images" }],
2356
+ },
2357
+ ],
2358
+ },
2359
+ url: "",
2360
+ quoteText: "",
2361
+ quoteAuthor: "",
2362
+ slug: "",
2363
+ visibility: "public",
2364
+ rating: 0,
2365
+ showTitle: false,
2366
+ showRating: false,
2367
+ collectionIds: [],
2368
+ replyToId: null,
2369
+ attachedTexts: [],
2370
+ attachmentOrder: ["client-a", "client-b"],
2371
+ mediaAttachments: [
2372
+ {
2373
+ clientId: "client-a",
2374
+ mediaId: "med_aaa",
2375
+ url: "/media/aaa.jpg",
2376
+ mimeType: "image/jpeg",
2377
+ name: "aaa.jpg",
2378
+ alt: "first",
2379
+ },
2380
+ ],
2381
+ savedAt: Date.now(),
2382
+ }),
2383
+ );
2384
+ }
2385
+
2386
+ it("restores uploaded media from the draft snapshot plus bridge-supplied uploads", async () => {
2387
+ const el = await createElement();
2388
+ seedDraftWithMedia();
2389
+
2390
+ // client-b's upload finished after the dialog closed — only the bridge
2391
+ // knows about it, so it arrives via restoreMedia instead of the snapshot.
2392
+ await el.openNew({
2393
+ restoreDraft: true,
2394
+ restoreToast: false,
2395
+ restoreMedia: [
2396
+ {
2397
+ clientId: "client-b",
2398
+ mediaId: "med_bbb",
2399
+ url: "/media/bbb.png",
2400
+ mimeType: "image/png",
2401
+ name: "bbb.png",
2402
+ },
2403
+ ],
2404
+ });
2405
+ await flushUpdates(el);
2406
+
2407
+ const editor = requireElement(
2408
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
2409
+ "expected compose editor",
2410
+ );
2411
+ const attachments = editor._attachments;
2412
+ expect(attachments.map((a) => a.clientId)).toEqual([
2413
+ "client-a",
2414
+ "client-b",
2415
+ ]);
2416
+ expect(attachments.map((a) => a.mediaId)).toEqual(["med_aaa", "med_bbb"]);
2417
+ expect(attachments.every((a) => a.status === "done")).toBe(true);
2418
+ expect(attachments[0].remoteUrl).toBe("/media/aaa.jpg");
2419
+ expect(attachments[0].alt).toBe("first");
2420
+ expect(editor._attachmentOrder).toEqual(["client-a", "client-b"]);
2421
+ });
2422
+
2423
+ it("suppresses the draft-restored toast when restoreToast is false", async () => {
2424
+ const container = document.createElement("div");
2425
+ container.id = "toast-container";
2426
+ document.body.appendChild(container);
2427
+ const el = await createElement();
2428
+ seedDraftWithMedia();
2429
+
2430
+ await el.openNew({ restoreDraft: true, restoreToast: false });
2431
+ await flushUpdates(el);
2432
+ expect(container.querySelector(".toast")).toBeNull();
2433
+
2434
+ el.reset();
2435
+ await el.openNew({ restoreDraft: true });
2436
+ await flushUpdates(el);
2437
+ expect(container.querySelector(".toast span")?.textContent).toBe(
2438
+ "Draft restored.",
2439
+ );
2440
+ });
2441
+
2442
+ const NOTE_TITLE_KEY = "jant:compose-note-title";
2443
+
2444
+ function titleToggle(el: JantComposeDialog, index = 0): HTMLButtonElement {
2445
+ return requireElement(
2446
+ el.querySelectorAll<HTMLButtonElement>(
2447
+ '.compose-tool-btn[title="Title"]',
2448
+ )[index] ?? null,
2449
+ "expected title toggle",
2450
+ );
2451
+ }
2452
+
2453
+ it("remembers a note's title toggle for the next new post", async () => {
2454
+ const el = await createElement();
2455
+ await el.openNew({ restoreDraft: false });
2456
+ await flushUpdates(el);
2457
+
2458
+ expect(el.querySelector(".compose-note-title")).not.toBeNull();
2459
+
2460
+ titleToggle(el).click();
2461
+ await flushUpdates(el);
2462
+
2463
+ expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBe("0");
2464
+ expect(el.querySelector(".compose-note-title")).toBeNull();
2465
+
2466
+ await el.openNew({ restoreDraft: false });
2467
+ await flushUpdates(el);
2468
+
2469
+ expect(el.querySelector(".compose-note-title")).toBeNull();
2470
+
2471
+ titleToggle(el).click();
2472
+ await flushUpdates(el);
2473
+
2474
+ expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBe("1");
2475
+
2476
+ await el.openNew({ restoreDraft: false });
2477
+ await flushUpdates(el);
2478
+
2479
+ expect(el.querySelector(".compose-note-title")).not.toBeNull();
2480
+ });
2481
+
2482
+ it("opens a fresh composer without the title field when it was last turned off", async () => {
2483
+ globalThis.localStorage.setItem(NOTE_TITLE_KEY, "0");
2484
+
2485
+ const el = await createElement();
2486
+
2487
+ expect(el.querySelector(".compose-note-title")).toBeNull();
2488
+ });
2489
+
2490
+ it("does not remember the title toggle from an edit", async () => {
2491
+ mockEditPost({ format: "note", title: "Hello", body: null });
2492
+
2493
+ const el = await createElement();
2494
+ await el.openEdit("pst_123");
2495
+ await flushUpdates(el);
2496
+
2497
+ titleToggle(el).click();
2498
+ await flushUpdates(el);
2499
+
2500
+ // Dropping this post's title says nothing about the next post's.
2501
+ expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBeNull();
2502
+ });
2503
+
2504
+ it("does not remember the title toggle from a reply", async () => {
2505
+ const el = await createElement();
2506
+ await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
2507
+ contentHtml: "<p>Parent</p>",
2508
+ dateText: "Mar 14",
2509
+ });
2510
+ await flushUpdates(el);
2511
+
2512
+ titleToggle(el).click();
2513
+ await flushUpdates(el);
2514
+
2515
+ expect(el.querySelector(".compose-note-title")).not.toBeNull();
2516
+ expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBeNull();
2517
+ });
2518
+
2519
+ it("does not remember the title toggle from a thread post", async () => {
2520
+ const el = await createElement();
2521
+ (el as unknown as { _addThreadItem: () => void })._addThreadItem();
2522
+ await flushUpdates(el);
1494
2523
 
1495
- await el.openNew({ collectionId: "col-1", restoreDraft: false });
1496
- await el.updateComplete;
2524
+ titleToggle(el).click();
2525
+ await flushUpdates(el);
1497
2526
 
1498
- expect(el._collectionIds).toEqual(["col-1"]);
1499
- expect(el._visibility).toBe("latest_hidden");
2527
+ expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBeNull();
1500
2528
  });
1501
2529
 
1502
- it("prepends the requested collection even when a local draft is restored", async () => {
2530
+ it("does not remember a title the editor reveals on its own", async () => {
1503
2531
  const el = await createElement();
2532
+ await el.openNew({ restoreDraft: false });
2533
+ await flushUpdates(el);
2534
+
2535
+ const editor = requireElement(
2536
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
2537
+ "expected compose editor",
2538
+ );
2539
+ // The fullscreen and H1-promotion paths land here. Neither is the author
2540
+ // stating a habit, so neither writes one down.
2541
+ editor.setEditorState(null, "Promoted", true);
2542
+ await flushUpdates(el);
1504
2543
 
2544
+ expect(el.querySelector(".compose-note-title")).not.toBeNull();
2545
+ expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBeNull();
2546
+ });
2547
+
2548
+ it("lets a restored draft's title state win over the remembered default", async () => {
2549
+ globalThis.localStorage.setItem(NOTE_TITLE_KEY, "0");
1505
2550
  globalThis.localStorage.setItem(
1506
2551
  "jant:compose-draft",
1507
2552
  JSON.stringify({
1508
2553
  format: "note",
1509
- title: "",
2554
+ title: "Draft title",
2555
+ showTitle: true,
1510
2556
  bodyJson: {
1511
2557
  type: "doc",
1512
2558
  content: [
@@ -1522,19 +2568,21 @@ describe("JantComposeDialog", () => {
1522
2568
  slug: "",
1523
2569
  visibility: "public",
1524
2570
  rating: 0,
1525
- showTitle: false,
1526
2571
  showRating: false,
1527
- collectionIds: ["col-1"],
2572
+ collectionIds: [],
1528
2573
  attachedTexts: [],
1529
2574
  attachmentOrder: [],
1530
2575
  savedAt: Date.now(),
1531
2576
  }),
1532
2577
  );
1533
2578
 
1534
- await el.openNew({ collectionId: "col-2" });
2579
+ const el = await createElement();
2580
+ await el.openNew();
1535
2581
  await flushUpdates(el);
1536
2582
 
1537
- expect(el._collectionIds).toEqual(["col-2", "col-1"]);
2583
+ expect(
2584
+ el.querySelector<HTMLInputElement>(".compose-note-title")?.value,
2585
+ ).toBe("Draft title");
1538
2586
  });
1539
2587
 
1540
2588
  it("includes a custom slug from the publish settings panel in the submit payload", async () => {
@@ -1565,13 +2613,14 @@ describe("JantComposeDialog", () => {
1565
2613
  await editor.updateComplete;
1566
2614
 
1567
2615
  const publishToggle = requireElement(
1568
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2616
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1569
2617
  "expected publish settings toggle",
1570
2618
  );
1571
2619
  publishToggle.click();
1572
2620
  await el.updateComplete;
1573
2621
  await flushUpdates(el);
1574
2622
 
2623
+ await openPostMeta(el);
1575
2624
  const slugInput = requireElement(
1576
2625
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
1577
2626
  "expected custom link input",
@@ -1611,7 +2660,7 @@ describe("JantComposeDialog", () => {
1611
2660
  const el = await createElement();
1612
2661
 
1613
2662
  const publishToggle = requireElement(
1614
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2663
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1615
2664
  "expected publish settings toggle",
1616
2665
  );
1617
2666
 
@@ -1619,6 +2668,7 @@ describe("JantComposeDialog", () => {
1619
2668
  await el.updateComplete;
1620
2669
  await flushUpdates(el);
1621
2670
 
2671
+ await openPostMeta(el);
1622
2672
  const slugInput = requireElement(
1623
2673
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
1624
2674
  "expected custom link input",
@@ -1633,6 +2683,7 @@ describe("JantComposeDialog", () => {
1633
2683
  publishToggle.click();
1634
2684
  await el.updateComplete;
1635
2685
 
2686
+ await openPostMeta(el);
1636
2687
  expect(el.querySelector(".compose-publish-slug-input")).not.toBeNull();
1637
2688
  });
1638
2689
 
@@ -1691,7 +2742,7 @@ describe("JantComposeDialog", () => {
1691
2742
  } as never);
1692
2743
 
1693
2744
  requireElement(
1694
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2745
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1695
2746
  "expected publish settings toggle",
1696
2747
  ).click();
1697
2748
  await el.updateComplete;
@@ -1762,7 +2813,7 @@ describe("JantComposeDialog", () => {
1762
2813
  } as never);
1763
2814
 
1764
2815
  requireElement(
1765
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2816
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1766
2817
  "expected publish settings toggle",
1767
2818
  ).click();
1768
2819
  await el.updateComplete;
@@ -1784,7 +2835,7 @@ describe("JantComposeDialog", () => {
1784
2835
  const el = await createElement();
1785
2836
 
1786
2837
  requireElement(
1787
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2838
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1788
2839
  "expected publish settings toggle",
1789
2840
  ).click();
1790
2841
  await el.updateComplete;
@@ -1812,13 +2863,14 @@ describe("JantComposeDialog", () => {
1812
2863
  await editor.updateComplete;
1813
2864
 
1814
2865
  const publishToggle = requireElement(
1815
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2866
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1816
2867
  "expected publish settings toggle",
1817
2868
  );
1818
2869
  publishToggle.click();
1819
2870
  await el.updateComplete;
1820
2871
  await flushUpdates(el);
1821
2872
 
2873
+ await openPostMeta(el);
1822
2874
  const slugInput = requireElement(
1823
2875
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
1824
2876
  "expected custom link input",
@@ -1863,7 +2915,6 @@ describe("JantComposeDialog", () => {
1863
2915
  el.querySelector<JantComposeEditor>("jant-compose-editor"),
1864
2916
  "expected compose editor",
1865
2917
  );
1866
- editor._showTitle = true;
1867
2918
  editor._title = "Hello World";
1868
2919
  editor._bodyJson = {
1869
2920
  type: "doc",
@@ -1874,13 +2925,14 @@ describe("JantComposeDialog", () => {
1874
2925
  await editor.updateComplete;
1875
2926
 
1876
2927
  const publishToggle = requireElement(
1877
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2928
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1878
2929
  "expected publish settings toggle",
1879
2930
  );
1880
2931
  publishToggle.click();
1881
2932
  await el.updateComplete;
1882
2933
  await flushUpdates(el);
1883
2934
 
2935
+ await openPostMeta(el);
1884
2936
  expect(
1885
2937
  el.querySelector(".compose-slug-suggestion-value")?.textContent?.trim(),
1886
2938
  ).toBe("/hello-world");
@@ -1901,11 +2953,11 @@ describe("JantComposeDialog", () => {
1901
2953
  ).toBeUndefined();
1902
2954
  });
1903
2955
 
1904
- it("shows visibility and custom link settings in the publish panel", async () => {
2956
+ it("keeps the publish panel to whole-submission settings", async () => {
1905
2957
  const el = await createElement();
1906
2958
 
1907
2959
  const publishToggle = requireElement(
1908
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2960
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1909
2961
  "expected publish settings toggle",
1910
2962
  );
1911
2963
  publishToggle.click();
@@ -1917,12 +2969,16 @@ describe("JantComposeDialog", () => {
1917
2969
  );
1918
2970
  expect(panel.textContent).not.toContain("Publish settings");
1919
2971
  expect(panel.textContent).toContain("Visibility");
1920
- expect(panel.textContent).toContain("Published on");
1921
- expect(panel.textContent).toContain("Custom link");
1922
2972
  expect(panel.textContent).not.toContain("Save as draft");
1923
2973
  expect(panel.textContent).not.toContain("Discard");
1924
- expect(panel.querySelector(".compose-publish-date-input")).not.toBeNull();
1925
- expect(panel.querySelector(".compose-publish-slug-input")).not.toBeNull();
2974
+ // Post-scoped settings are not in here — they live on the post.
2975
+ expect(panel.textContent).not.toContain("Published on");
2976
+ expect(panel.textContent).not.toContain("Custom link");
2977
+
2978
+ await openPostMeta(el);
2979
+ expect(el.querySelector(".compose-publish-date-input")).not.toBeNull();
2980
+ await openPostMeta(el);
2981
+ expect(el.querySelector(".compose-publish-slug-input")).not.toBeNull();
1926
2982
  });
1927
2983
 
1928
2984
  it("clears the custom slug with the reset action", async () => {
@@ -1938,13 +2994,14 @@ describe("JantComposeDialog", () => {
1938
2994
 
1939
2995
  const el = await createElement();
1940
2996
  const publishToggle = requireElement(
1941
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2997
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1942
2998
  "expected publish settings toggle",
1943
2999
  );
1944
3000
  publishToggle.click();
1945
3001
  await el.updateComplete;
1946
3002
  await flushUpdates(el);
1947
3003
 
3004
+ await openPostMeta(el);
1948
3005
  const slugInput = requireElement(
1949
3006
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
1950
3007
  "expected custom link input",
@@ -1964,6 +3021,7 @@ describe("JantComposeDialog", () => {
1964
3021
  ).click();
1965
3022
  await flushUpdates(el);
1966
3023
 
3024
+ await openPostMeta(el);
1967
3025
  expect(
1968
3026
  requireElement(
1969
3027
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
@@ -1986,7 +3044,7 @@ describe("JantComposeDialog", () => {
1986
3044
  await el.updateComplete;
1987
3045
 
1988
3046
  const publishToggle = requireElement(
1989
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3047
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1990
3048
  "expected publish settings toggle",
1991
3049
  );
1992
3050
  publishToggle.click();
@@ -2018,18 +3076,18 @@ describe("JantComposeDialog", () => {
2018
3076
  el.querySelector<JantComposeEditor>("jant-compose-editor"),
2019
3077
  "expected compose editor",
2020
3078
  );
2021
- editor._showTitle = true;
2022
3079
  editor._title = "Hello World";
2023
3080
  await editor.updateComplete;
2024
3081
 
2025
3082
  const publishToggle = requireElement(
2026
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3083
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
2027
3084
  "expected publish settings toggle",
2028
3085
  );
2029
3086
  publishToggle.click();
2030
3087
  await el.updateComplete;
2031
3088
  await flushUpdates(el);
2032
3089
 
3090
+ await openPostMeta(el);
2033
3091
  expect(
2034
3092
  el.querySelector(".compose-slug-suggestion-value")?.textContent?.trim(),
2035
3093
  ).toBe("/hello-world");
@@ -2079,13 +3137,14 @@ describe("JantComposeDialog", () => {
2079
3137
  await editor.updateComplete;
2080
3138
 
2081
3139
  const publishToggle = requireElement(
2082
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3140
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
2083
3141
  "expected publish settings toggle",
2084
3142
  );
2085
3143
  publishToggle.click();
2086
3144
  await el.updateComplete;
2087
3145
  await flushUpdates(el);
2088
3146
 
3147
+ await openPostMeta(el);
2089
3148
  const slugInput = requireElement(
2090
3149
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
2091
3150
  "expected custom link input",
@@ -2136,11 +3195,12 @@ describe("JantComposeDialog", () => {
2136
3195
  await editor.updateComplete;
2137
3196
 
2138
3197
  requireElement(
2139
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3198
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
2140
3199
  "expected publish settings toggle",
2141
3200
  ).click();
2142
3201
  await el.updateComplete;
2143
3202
 
3203
+ await openPostMeta(el);
2144
3204
  const slugInput = requireElement(
2145
3205
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
2146
3206
  "expected custom link input",
@@ -2188,11 +3248,12 @@ describe("JantComposeDialog", () => {
2188
3248
  await editor.updateComplete;
2189
3249
 
2190
3250
  requireElement(
2191
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3251
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
2192
3252
  "expected publish settings toggle",
2193
3253
  ).click();
2194
3254
  await el.updateComplete;
2195
3255
 
3256
+ await openPostMeta(el);
2196
3257
  const slugInput = requireElement(
2197
3258
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
2198
3259
  "expected custom link input",
@@ -2306,7 +3367,6 @@ describe("JantComposeDialog", () => {
2306
3367
  slug: "",
2307
3368
  visibility: "public",
2308
3369
  rating: 0,
2309
- showTitle: false,
2310
3370
  showRating: false,
2311
3371
  collectionIds: ["col-stale"],
2312
3372
  replyToId,
@@ -2451,15 +3511,15 @@ describe("JantComposeDialog", () => {
2451
3511
  ];
2452
3512
  await el.updateComplete;
2453
3513
 
2454
- const threadLayout = requireElement(
2455
- el.querySelector<HTMLElement>(".compose-thread-compose-layout"),
2456
- "expected thread compose layout",
3514
+ const scroller = requireElement(
3515
+ el.querySelector<HTMLElement>(".compose-scroll"),
3516
+ "expected compose scroll region",
2457
3517
  );
2458
- Object.defineProperty(threadLayout, "scrollHeight", {
3518
+ Object.defineProperty(scroller, "scrollHeight", {
2459
3519
  configurable: true,
2460
3520
  value: 640,
2461
3521
  });
2462
- threadLayout.scrollTop = 120;
3522
+ scroller.scrollTop = 120;
2463
3523
 
2464
3524
  (
2465
3525
  el as unknown as {
@@ -2468,7 +3528,7 @@ describe("JantComposeDialog", () => {
2468
3528
  )._addThreadItem();
2469
3529
  await flushUpdates(el);
2470
3530
 
2471
- expect(threadLayout.scrollTop).toBe(640);
3531
+ expect(scroller.scrollTop).toBe(640);
2472
3532
  });
2473
3533
 
2474
3534
  it("omits visibility from locked edit submissions", async () => {
@@ -2512,6 +3572,20 @@ describe("JantComposeDialog", () => {
2512
3572
  );
2513
3573
  });
2514
3574
 
3575
+ it("gives the collection trigger one glyph and no chevron", async () => {
3576
+ const el = await createElement();
3577
+ const trigger = requireElement(
3578
+ el.querySelector<HTMLButtonElement>(".compose-collection-trigger"),
3579
+ "expected collection trigger",
3580
+ );
3581
+
3582
+ expect(trigger.querySelectorAll("svg").length).toBe(1);
3583
+ expect(
3584
+ trigger.querySelector(".compose-collection-trigger-svg"),
3585
+ ).not.toBeNull();
3586
+ expect(trigger.querySelector(".compose-collection-chevron")).toBeNull();
3587
+ });
3588
+
2515
3589
  it("collection selector toggles IDs", async () => {
2516
3590
  const el = await createElement();
2517
3591
 
@@ -3042,13 +4116,10 @@ describe("JantComposeDialog", () => {
3042
4116
  };
3043
4117
  await editor.updateComplete;
3044
4118
 
3045
- // Click the draft header button — should show confirm panel
3046
- const draftBtn = requireElement(
3047
- el.querySelector<HTMLButtonElement>(".compose-dialog-header-btn"),
3048
- "expected draft button",
3049
- );
3050
- draftBtn.click();
3051
- await el.updateComplete;
4119
+ // Click the drafts row in the options panel — should show confirm panel
4120
+ await openPublishPanel(el);
4121
+ requireElement(draftsRow(el), "expected drafts row").click();
4122
+ await flushUpdates(el);
3052
4123
 
3053
4124
  expect(el._confirmPanelOpen).toBe(true);
3054
4125
  expect(el.querySelector(".compose-confirm-panel")).not.toBeNull();
@@ -3065,13 +4136,10 @@ describe("JantComposeDialog", () => {
3065
4136
  }),
3066
4137
  );
3067
4138
 
3068
- // Click the draft header button — should open drafts panel
3069
- const draftBtn = requireElement(
3070
- el.querySelector<HTMLButtonElement>(".compose-dialog-header-btn"),
3071
- "expected draft button",
3072
- );
3073
- draftBtn.click();
3074
- await el.updateComplete;
4139
+ // Click the drafts row in the options panel — should open drafts panel
4140
+ await openPublishPanel(el);
4141
+ requireElement(draftsRow(el), "expected drafts row").click();
4142
+ await flushUpdates(el);
3075
4143
 
3076
4144
  expect(el._draftsPanelOpen).toBe(true);
3077
4145
 
@@ -3132,143 +4200,597 @@ describe("JantComposeDialog", () => {
3132
4200
  menu.querySelector<HTMLAnchorElement>('a[role="menuitem"]'),
3133
4201
  "expected preview link",
3134
4202
  );
3135
- expect(previewLink.getAttribute("href")).toBe("/blog/preview/draft-slug");
3136
- expect(previewLink.target).toBe("_blank");
3137
- expect(previewLink.rel).toBe("noopener noreferrer");
3138
-
3139
- const fetchSpy = vi.spyOn(globalThis, "fetch");
3140
- previewLink.addEventListener("click", (event) => event.preventDefault());
3141
- previewLink.click();
3142
- await el.updateComplete;
3143
-
3144
- expect(fetchSpy).not.toHaveBeenCalled();
3145
- expect(el._draftsPanelOpen).toBe(true);
3146
- expect(el._draftMenuOpenId).toBeNull();
4203
+ expect(previewLink.getAttribute("href")).toBe("/blog/preview/draft-slug");
4204
+ expect(previewLink.target).toBe("_blank");
4205
+ expect(previewLink.rel).toBe("noopener noreferrer");
4206
+
4207
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
4208
+ previewLink.addEventListener("click", (event) => event.preventDefault());
4209
+ previewLink.click();
4210
+ await el.updateComplete;
4211
+
4212
+ expect(fetchSpy).not.toHaveBeenCalled();
4213
+ expect(el._draftsPanelOpen).toBe(true);
4214
+ expect(el._draftMenuOpenId).toBeNull();
4215
+ });
4216
+
4217
+ it("uses title or quote text for draft previews and identifies each format", async () => {
4218
+ const el = await createElement();
4219
+ el._draftsPanelOpen = true;
4220
+ el._draftsLoading = false;
4221
+ el._drafts = [
4222
+ {
4223
+ id: "pst_note",
4224
+ slug: "note-draft",
4225
+ format: "note",
4226
+ title: "A titled note",
4227
+ bodyText: "This body should not be the preview",
4228
+ bodyHtml: "<p>This body should not be the preview</p>",
4229
+ url: null,
4230
+ quoteText: null,
4231
+ replyToId: null,
4232
+ updatedAt: 0,
4233
+ mediaAttachments: [],
4234
+ },
4235
+ {
4236
+ id: "pst_quote",
4237
+ slug: "quote-draft",
4238
+ format: "quote",
4239
+ title: "Quote title",
4240
+ bodyText: "Supporting note",
4241
+ bodyHtml: "<p>Supporting note</p>",
4242
+ url: null,
4243
+ quoteText: "The quoted words come first.",
4244
+ replyToId: null,
4245
+ updatedAt: 0,
4246
+ mediaAttachments: [],
4247
+ },
4248
+ {
4249
+ id: "pst_link",
4250
+ slug: "link-draft",
4251
+ format: "link",
4252
+ title: null,
4253
+ bodyText: "A link note",
4254
+ bodyHtml: "<p>A link note</p>",
4255
+ url: "https://example.com",
4256
+ quoteText: null,
4257
+ replyToId: null,
4258
+ updatedAt: 0,
4259
+ mediaAttachments: [],
4260
+ },
4261
+ ];
4262
+ await el.updateComplete;
4263
+
4264
+ const rows = el.querySelectorAll<HTMLElement>(".compose-draft-item");
4265
+ expect(rows).toHaveLength(3);
4266
+ expect(rows[0]?.querySelector(".compose-draft-preview")?.textContent).toBe(
4267
+ "A titled note",
4268
+ );
4269
+ expect(rows[1]?.querySelector(".compose-draft-preview")?.textContent).toBe(
4270
+ "The quoted words come first.",
4271
+ );
4272
+ expect(rows[2]?.querySelector(".compose-draft-preview")?.textContent).toBe(
4273
+ "A link note",
4274
+ );
4275
+ expect(
4276
+ Array.from(el.querySelectorAll(".compose-draft-format")).map(
4277
+ (format) => format.textContent,
4278
+ ),
4279
+ ).toEqual(["Note", "Quote", "Link"]);
4280
+ });
4281
+
4282
+ it("does not dispatch submit when loading", async () => {
4283
+ const el = await createElement();
4284
+ el._loading = true;
4285
+ await el.updateComplete;
4286
+
4287
+ let dispatched = false;
4288
+ el.addEventListener("jant:compose-submit-deferred", () => {
4289
+ dispatched = true;
4290
+ });
4291
+
4292
+ const postBtn = requireElement(
4293
+ el.querySelector<HTMLButtonElement>(".compose-publish-main"),
4294
+ "expected post button",
4295
+ );
4296
+ postBtn.click();
4297
+
4298
+ expect(dispatched).toBe(false);
4299
+ });
4300
+
4301
+ it("loading state shows spinner in submit button", async () => {
4302
+ const el = await createElement();
4303
+ el._loading = true;
4304
+ await el.updateComplete;
4305
+
4306
+ const spinner = el.querySelector(".compose-publish-main .animate-spin");
4307
+ expect(spinner).not.toBeNull();
4308
+ });
4309
+
4310
+ it("no old media picker dialog is rendered", async () => {
4311
+ const el = await createElement();
4312
+
4313
+ expect(el.querySelector("#compose-media-picker")).toBeNull();
4314
+ expect(el.querySelector(".compose-media-picker")).toBeNull();
4315
+ });
4316
+
4317
+ it("editor renders attachments when present", async () => {
4318
+ const el = await createElement();
4319
+ const editor = requireElement(
4320
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
4321
+ "expected compose editor",
4322
+ );
4323
+
4324
+ // Simulate adding an attachment
4325
+ const blob = new Blob(["fake-image"], { type: "image/png" });
4326
+ const file = new File([blob], "test.png", { type: "image/png" });
4327
+ const previewUrl = URL.createObjectURL(blob);
4328
+
4329
+ editor._attachments = [
4330
+ {
4331
+ clientId: "test-id-1",
4332
+ file,
4333
+ previewUrl,
4334
+ status: "done",
4335
+ progress: null,
4336
+ mediaId: "media-1",
4337
+ alt: "",
4338
+ error: null,
4339
+ posterUrl: null,
4340
+ remoteUrl: null,
4341
+ summary: null,
4342
+ chars: null,
4343
+ },
4344
+ ];
4345
+ editor._attachmentOrder = ["test-id-1"];
4346
+ await editor.updateComplete;
4347
+
4348
+ // Thumbnail strip should be visible
4349
+ expect(editor.querySelector(".compose-attachments")).not.toBeNull();
4350
+ expect(editor.querySelector(".compose-attachment-thumb")).not.toBeNull();
4351
+ // ALT button should be visible
4352
+ expect(editor.querySelector(".compose-attachment-alt")).not.toBeNull();
4353
+ // Media tool button should show inline "Add" label
4354
+ const mediaBtn =
4355
+ editor.querySelector<HTMLButtonElement>(".compose-tool-btn");
4356
+ expect(mediaBtn?.querySelector(".compose-tool-label")?.textContent).toBe(
4357
+ "Add",
4358
+ );
4359
+
4360
+ URL.revokeObjectURL(previewUrl);
4361
+ });
4362
+
4363
+ it("puts the single-post editor straight inside the one scroll region", async () => {
4364
+ const el = await createElement();
4365
+
4366
+ const scroller = requireElement(
4367
+ el.querySelector<HTMLElement>(".compose-scroll"),
4368
+ "expected compose scroll region",
4369
+ );
4370
+ const editor = requireElement(
4371
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
4372
+ "expected compose editor",
4373
+ );
4374
+
4375
+ // The rules that lay the editor out are keyed on
4376
+ // `.compose-scroll > jant-compose-editor`. Any wrapper breaks them —
4377
+ // including a `display: contents` one, which drops the box but not the DOM
4378
+ // parentage the selector matches on.
4379
+ expect(editor.parentElement).toBe(scroller);
4380
+ // The action row is the composer's pinned floor; it must stay outside.
4381
+ expect(scroller.querySelector(".compose-action-row")).toBeNull();
4382
+ expect(el.querySelector(".compose-action-row")).not.toBeNull();
4383
+ });
4384
+
4385
+ it("scrolls in exactly one place across every compose mode", async () => {
4386
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4387
+
4388
+ // `.compose-scroll` owns the scrolling for single-post, reply and thread
4389
+ // alike. Anything nested inside it that scrolls on its own is the bug this
4390
+ // replaced: three stacked scrollers, and previews shrunk to fit them.
4391
+ expect(css).toMatch(
4392
+ /\.compose-scroll\s*\{[\s\S]*flex:\s*1;[\s\S]*min-height:\s*0;[\s\S]*overflow-y:\s*auto;/,
4393
+ );
4394
+ expect(css).not.toMatch(/\.compose-body\s*\{[^}]*overflow-y:\s*auto;/);
4395
+ expect(css).not.toMatch(
4396
+ /\.compose-thread-compose-layout\s*\{[^}]*overflow-y:\s*auto;/,
4397
+ );
4398
+ expect(css).not.toMatch(
4399
+ /\.compose-editor-row\s+\.compose-attachments-dock\s*\{[^}]*overflow-y:\s*auto;/,
4400
+ );
4401
+
4402
+ // Nothing between the scroll region and the text may clip or claim a fixed
4403
+ // share of the height — every block is sized by its own content.
4404
+ expect(css).not.toMatch(
4405
+ /\.compose-editor-row\s*\{[^}]*overflow:\s*hidden;/,
4406
+ );
4407
+ expect(css).not.toMatch(
4408
+ /\.compose-thread-layout\s*\{[^}]*overflow:\s*hidden;/,
4409
+ );
4410
+ });
4411
+
4412
+ it("lets attachment previews take the room the pinned toolbar used to need", () => {
4413
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4414
+
4415
+ expect(css).toMatch(
4416
+ /\.compose-attachment-img\s*\{[\s\S]*max-height:\s*min\(420px,\s*46dvh\);/,
4417
+ );
4418
+ expect(css).toMatch(
4419
+ /\.compose-attachment:only-child\s+\.compose-attachment-img\s*\{[\s\S]*max-height:\s*min\(460px,\s*52dvh\);/,
4420
+ );
4421
+ expect(css).toMatch(
4422
+ /\.compose-attachment:not\(:only-child\)\s+\.compose-attachment-img\s*\{[\s\S]*height:\s*min\(340px,\s*40dvh\);/,
4423
+ );
4424
+ // Reply compose no longer shrinks its previews to 96×72 to spare the
4425
+ // toolbar — one preview size everywhere.
4426
+ expect(css).not.toMatch(/--compose-reply-attachment-width/);
4427
+ });
4428
+
4429
+ it("routes a format change straight off the single-post editor", async () => {
4430
+ const el = await createElement();
4431
+ const editor = requireElement(
4432
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
4433
+ "expected compose editor",
4434
+ );
4435
+
4436
+ editor.dispatchEvent(
4437
+ new CustomEvent("jant:format-change", {
4438
+ detail: { format: "quote" },
4439
+ bubbles: true,
4440
+ }),
4441
+ );
4442
+ await flushUpdates(el);
4443
+
4444
+ expect(el._format).toBe("quote");
4445
+ });
4446
+
4447
+ it("shows the replied-to post at the same scale as the reply", () => {
4448
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4449
+
4450
+ // Same ceiling as the composer's own previews — the parent is not a
4451
+ // thumbnail strip.
4452
+ expect(css).toMatch(
4453
+ /\.compose-reply-context-body img,[\s\S]*?video\s*\{[\s\S]*?max-height:\s*min\(420px,\s*46dvh\);/,
4454
+ );
4455
+ // One rule governs every picture in the context. A second one keyed on
4456
+ // `.compose-reply-context-media-img` would sit at lower specificity than
4457
+ // the `…-body img` rule above it and never apply — the media strip is
4458
+ // inside the body.
4459
+ expect(css).not.toMatch(/\.compose-reply-context-media-img\s*\{/);
4460
+
4461
+ // Expanding hands the post its full height; a capped, separately scrolling
4462
+ // box is what left no room for full-size pictures.
4463
+ expect(css).toMatch(
4464
+ /\.compose-reply-context\.expanded\s*\{\s*max-height:\s*none;\s*overflow:\s*visible;/,
4465
+ );
4466
+ // Fullscreen's collapsed cap must not outrank that.
4467
+ expect(css).toMatch(
4468
+ /\.compose-fullscreen-thread-layout\s+\.compose-reply-context:not\(\.expanded\)\s*\{\s*max-height:\s*140px;/,
4469
+ );
4470
+ });
4471
+
4472
+ it("keeps reply compose tools inside the editor's text column", () => {
4473
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4474
+
4475
+ // `flex: 1` on the editor is horizontal — it claims the width the rail dot
4476
+ // leaves. Height comes from content.
4477
+ expect(css).toMatch(
4478
+ /\.compose-editor-row\s*>\s*jant-compose-editor\s*\{[\s\S]*flex:\s*1;[\s\S]*min-width:\s*0;/,
4479
+ );
4480
+ expect(css).toMatch(
4481
+ /\.compose-reply-compose-layout\s+\.compose-thread-post-header\s*\+\s*\.compose-body\s*\{[\s\S]*padding-top:\s*12px;/,
4482
+ );
4483
+ expect(css).toMatch(
4484
+ /@media \(max-width:\s*760px\),\s*\(hover:\s*none\) and \(pointer:\s*coarse\)\s*\{[\s\S]*\.compose-thread-layout\s*\{[\s\S]*padding-top:\s*0\.75rem;/,
4485
+ );
4486
+ expect(css).toMatch(
4487
+ /\.compose-dialog,[\s\S]*\.compose-page-shell\s*>\s*jant-compose-dialog\s*\{[\s\S]*--compose-quote-input-size:\s*var\(--type-content-subtitle\);[\s\S]*--compose-quote-input-leading:\s*1\.32;/,
4488
+ );
4489
+ // On the page the card's own padding is the text column, so every row
4490
+ // inside it reads a zero gutter and only restates its right.
4491
+ expect(css).toMatch(
4492
+ /\.compose-page-shell\s+\.compose-dialog-inner-page\s*\{[\s\S]*--compose-gutter:\s*0px;/,
4493
+ );
4494
+ expect(css).toMatch(
4495
+ /\.compose-page-shell\s+\.compose-dialog-inner-page\s+\.compose-thread-post-header\s*\{\s*padding-inline-end:\s*0;/,
4496
+ );
4497
+ expect(css).toMatch(
4498
+ /\.compose-quote-text\s*\{[\s\S]*font-size:\s*var\(--compose-quote-input-size\);[\s\S]*line-height:\s*var\(--compose-quote-input-leading\);/,
4499
+ );
4500
+ expect(css).toMatch(
4501
+ /\.compose-reply-compose-layout\s*\{[\s\S]*--compose-title-input-size:\s*calc\(var\(--type-content-body\) \* 1\.2\);[\s\S]*--compose-quote-input-size:\s*calc\(var\(--type-content-body\) \* 1\.06\);[\s\S]*--compose-quote-input-leading:\s*1\.42;[\s\S]*--compose-inline-input-size:\s*var\(--type-base\);/,
4502
+ );
4503
+ expect(css).toMatch(
4504
+ /\.compose-reply-compose-layout\s+\.compose-quote-wrap\s*\{[\s\S]*margin-top:\s*0\.4rem;[\s\S]*padding:\s*24px 18px 18px;[\s\S]*border-radius:\s*0\.95rem;/,
4505
+ );
4506
+ expect(css).toMatch(
4507
+ /\.compose-reply-compose-layout\s+\.compose-link-url-wrap\s*\{[\s\S]*margin-top:\s*0\.4rem;/,
4508
+ );
4509
+ // The reply layout tunes only the lead-in. Height is the shared four-line
4510
+ // floor, which reads this layout's own leading and type size, so a
4511
+ // `min-height` here would just restate what the base rule already knows.
4512
+ expect(css).toMatch(
4513
+ /\.compose-reply-compose-layout\s+\.compose-quote-text\s*\{\s*padding-top:\s*0\.35rem;\s*\}/,
4514
+ );
4515
+ expect(css).not.toMatch(
4516
+ /\.compose-reply-compose-layout\s+\.compose-quote-text\s*\{[^}]*min-height:/,
4517
+ );
4518
+ expect(css).toMatch(
4519
+ /\.compose-reply-compose-layout\s+\.compose-tiptap-body\s+\.tiptap\s*\{[\s\S]*font-size:\s*var\(--type-content-body\);/,
4520
+ );
3147
4521
  });
3148
4522
 
3149
- it("does not dispatch submit when loading", async () => {
3150
- const el = await createElement();
3151
- el._loading = true;
3152
- await el.updateComplete;
3153
-
3154
- let dispatched = false;
3155
- el.addEventListener("jant:compose-submit-deferred", () => {
3156
- dispatched = true;
3157
- });
4523
+ it("opens every compose text surface at a floor stated in lines", () => {
4524
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
3158
4525
 
3159
- const postBtn = requireElement(
3160
- el.querySelector<HTMLButtonElement>(".compose-publish-main"),
3161
- "expected post button",
4526
+ // Two lines for the editor, four for the quote — as multiples of the
4527
+ // leading, not pixels, so the floor follows the type instead of going
4528
+ // stale next to it.
4529
+ expect(css).toMatch(
4530
+ /\.compose-tiptap-body \.tiptap\s*\{[\s\S]*min-height:\s*calc\(var\(--type-body-leading\) \* 2em\);/,
4531
+ );
4532
+ expect(css).toMatch(
4533
+ /\.compose-quote-text\s*\{[\s\S]*min-height:\s*calc\(var\(--compose-quote-input-leading\) \* 4em\);/,
3162
4534
  );
3163
- postBtn.click();
3164
4535
 
3165
- expect(dispatched).toBe(false);
4536
+ // One floor for every editor: a note's body and the "thoughts" under a
4537
+ // link or a quote all open the same way.
4538
+ expect(css).not.toMatch(/\.compose-tiptap-thoughts/);
3166
4539
  });
3167
4540
 
3168
- it("loading state shows spinner in submit button", async () => {
3169
- const el = await createElement();
3170
- el._loading = true;
3171
- await el.updateComplete;
4541
+ it("runs the thread rail from the first dot to the last post's, and no further", () => {
4542
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
3172
4543
 
3173
- const spinner = el.querySelector(".compose-publish-main .animate-spin");
3174
- expect(spinner).not.toBeNull();
4544
+ // Drawn per row rather than as one strip down the layout, which is what
4545
+ // lets both ends be trimmed back to a dot.
4546
+ expect(css).not.toMatch(/\.compose-thread-layout::before\s*\{/);
4547
+ expect(css).toMatch(
4548
+ /\.compose-thread-layout > \*::before\s*\{[\s\S]*top:\s*0;[\s\S]*bottom:\s*0;/,
4549
+ );
4550
+ expect(css).toMatch(
4551
+ /\.compose-thread-layout > :first-child::before\s*\{\s*top:\s*var\(--compose-thread-dot-center\);/,
4552
+ );
4553
+ expect(css).toMatch(
4554
+ /\.compose-editor-row:not\(:has\(~ \.compose-editor-row\)\)::before\s*\{\s*bottom:\s*auto;\s*height:\s*var\(--compose-thread-dot-center\);/,
4555
+ );
4556
+ // The "add" row is a placeholder, not a post — the rail stops before it.
4557
+ expect(css).toMatch(
4558
+ /\.compose-thread-layout > \.compose-thread-add-row::before\s*\{\s*display:\s*none;/,
4559
+ );
4560
+
4561
+ // The dot reads its offset off the same centre the rail is trimmed to, so
4562
+ // resizing the marker cannot leave the line pointing past it.
4563
+ expect(css).toMatch(
4564
+ /\.compose-thread-dot\s*\{[\s\S]*margin-top:\s*calc\(\s*var\(--compose-thread-dot-center\) -\s*var\(--compose-thread-row-padding-top\) -\s*var\(--site-thread-marker-size\) \/ 2\s*\);/,
4565
+ );
3175
4566
  });
3176
4567
 
3177
- it("no old media picker dialog is rendered", async () => {
3178
- const el = await createElement();
4568
+ it("starts every compose row's ink on one text column", () => {
4569
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
3179
4570
 
3180
- expect(el.querySelector("#compose-media-picker")).toBeNull();
3181
- expect(el.querySelector(".compose-media-picker")).toBeNull();
3182
- });
4571
+ // One knob for the column itself, read by every row rather than restated
4572
+ // as a literal per row.
4573
+ expect(css).toMatch(
4574
+ /\.compose-dialog,\s*\.compose-page-shell\s*>\s*jant-compose-dialog\s*\{[\s\S]*--compose-gutter:\s*20px;/,
4575
+ );
4576
+ expect(css).toMatch(
4577
+ /@media \(min-width:\s*700px\)\s*\{[\s\S]*--compose-gutter:\s*24px;/,
4578
+ );
4579
+ // Zero wherever the indent already comes from outside the editor.
4580
+ expect(css).toMatch(
4581
+ /\.compose-editor-row\s*\{\s*\/\*[\s\S]*?\*\/\s*--compose-gutter:\s*0px;/,
4582
+ );
4583
+
4584
+ for (const rule of [
4585
+ /\.compose-body\s*\{[^}]*padding:\s*16px 20px 12px var\(--compose-gutter\);/,
4586
+ /\.compose-tools-row\s*\{[\s\S]*padding:\s*6px 10px 6px var\(--compose-gutter\);/,
4587
+ /\.compose-attachments-dock\s*\{\s*padding:\s*8px 20px 4px var\(--compose-gutter\);/,
4588
+ /\.compose-thread-post-header\s*\{[\s\S]*padding:\s*10px 16px 2px var\(--compose-gutter\);/,
4589
+ /\.compose-add-thread-trigger\s*\{\s*padding:\s*1px 10px 4px var\(--compose-gutter\);/,
4590
+ /\.compose-action-row\s*\{\s*padding:\s*10px 12px 14px var\(--compose-gutter\);/,
4591
+ ]) {
4592
+ expect(css).toMatch(rule);
4593
+ }
3183
4594
 
3184
- it("editor renders attachments when present", async () => {
3185
- const el = await createElement();
3186
- const editor = requireElement(
3187
- el.querySelector<JantComposeEditor>("jant-compose-editor"),
3188
- "expected compose editor",
4595
+ // Each control hangs its box back out of the column by its own optical
4596
+ // inset, so the glyph or the label lands on the edge and not the box.
4597
+ expect(css).toMatch(
4598
+ /\.compose-tools-row\s*\{[\s\S]*--compose-tool-hang:\s*12px;/,
4599
+ );
4600
+ expect(css).toMatch(
4601
+ /\.compose-tools-row > :first-child\s*\{\s*margin-inline-start:\s*calc\(-1 \* var\(--compose-tool-hang\)\);/,
4602
+ );
4603
+ expect(css).toMatch(
4604
+ /\.compose-thread-add-btn\s*\{[\s\S]*padding:\s*5px var\(--compose-add-hang\);\s*margin-inline-start:\s*calc\(-1 \* var\(--compose-add-hang\)\);/,
4605
+ );
4606
+ expect(css).toMatch(
4607
+ /\.compose-collection-select\s*\{[\s\S]*margin-inline-start:\s*calc\(\s*-1 \* \(var\(--compose-collection-pad-icon\) \+ 1px\)\s*\);/,
3189
4608
  );
3190
4609
 
3191
- // Simulate adding an attachment
3192
- const blob = new Blob(["fake-image"], { type: "image/png" });
3193
- const file = new File([blob], "test.png", { type: "image/png" });
3194
- const previewUrl = URL.createObjectURL(blob);
4610
+ // The ad-hoc thread-only indents these replaced are gone — they were what
4611
+ // let each mode drift to its own edge in the first place.
4612
+ expect(css).not.toMatch(/margin-inline-start:\s*-10px;/);
4613
+ expect(css).not.toMatch(/\.compose-thread-layout \.compose-tools-row\s*\{/);
4614
+ // One "Add to thread" button, not two rule sets that have to stay in step.
4615
+ expect(css).not.toMatch(/\.compose-add-thread-btn/);
4616
+ });
3195
4617
 
3196
- editor._attachments = [
3197
- {
3198
- clientId: "test-id-1",
3199
- file,
3200
- previewUrl,
3201
- status: "done",
3202
- progress: null,
3203
- mediaId: "media-1",
3204
- alt: "",
3205
- error: null,
3206
- posterUrl: null,
3207
- summary: null,
3208
- chars: null,
3209
- },
3210
- ];
3211
- editor._attachmentOrder = ["test-id-1"];
3212
- await editor.updateComplete;
4618
+ it("declares one marker vocabulary for the compose and reading rails", () => {
4619
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
3213
4620
 
3214
- // Thumbnail strip should be visible
3215
- expect(editor.querySelector(".compose-attachments")).not.toBeNull();
3216
- expect(editor.querySelector(".compose-attachment-thumb")).not.toBeNull();
3217
- // ALT button should be visible
3218
- expect(editor.querySelector(".compose-attachment-alt")).not.toBeNull();
3219
- // Media tool button should show inline "Add" label
3220
- const mediaBtn =
3221
- editor.querySelector<HTMLButtonElement>(".compose-tool-btn");
3222
- expect(mediaBtn?.querySelector(".compose-tool-label")?.textContent).toBe(
3223
- "Add",
4621
+ // Both rails draw the same dot; only the surface behind it differs.
4622
+ // `[^}]*` rather than `[\s\S]*` throughout: a greedy any-char run walks past
4623
+ // the rule's closing brace and happily matches a later rule's declaration.
4624
+ expect(css).toMatch(
4625
+ /\.thread-group,\s*\.compose-thread-layout\s*\{\s*--site-thread-marker-size:\s*7px;[^}]*--site-thread-marker-gap:\s*9px;/,
3224
4626
  );
3225
-
3226
- URL.revokeObjectURL(previewUrl);
4627
+ expect(css).toMatch(
4628
+ /\.compose-thread-layout\s*\{\s*--site-thread-marker-surface:\s*var\(--compose-paper-bg\);\s*\}/,
4629
+ );
4630
+ // 7:1.5 marker-to-rail on both, from one token — compose used to hardcode
4631
+ // its own 1.5px while reading ran a 1px hairline under the same 7px dot.
4632
+ expect(css).toMatch(/--site-thread-rail-line-width:\s*1\.5px;/);
4633
+ expect(css).not.toMatch(
4634
+ /\.thread-group-preview,\s*\.thread-group-detail\s*\{[^}]*--site-thread-rail-line-width:/,
4635
+ );
4636
+ expect(css).toMatch(
4637
+ /\.compose-thread-layout > \*::before\s*\{[^}]*width:\s*var\(--site-thread-rail-line-width\);\s*margin-left:\s*calc\(var\(--site-thread-rail-line-width\) \/ -2\);/,
4638
+ );
4639
+ // The reading rail's fade-in gradient stays — it softens where collapsed
4640
+ // ancestor context begins, which compose solves by trimming per row.
4641
+ expect(css).toMatch(
4642
+ /\.thread-group-preview::before,\s*\.thread-group-detail::before\s*\{\s*background:\s*linear-gradient\(/,
4643
+ );
4644
+ // Both rails now stop at the last dot. Reading masks its group-wide line
4645
+ // from that dot down; `> ` keeps the mask off the last ancestor inside
4646
+ // `.thread-context-shell`, which the rail must run straight through.
4647
+ expect(css).toMatch(
4648
+ /\.thread-group > \.thread-item:last-child::after\s*\{[^}]*top:\s*50%;\s*bottom:\s*0;\s*width:\s*var\(--site-thread-rail-line-width\);\s*background-color:\s*var\(--site-thread-marker-surface\);/,
4649
+ );
4650
+ expect(css).not.toMatch(/\.thread-group \.thread-item:last-child::after/);
4651
+ // The ring is reading's alone: the token is declared on `.thread-group`, and
4652
+ // compose's dot rule never reads it. The compose rail is quiet by design —
4653
+ // it already has a card edge, a format switcher and a "1/3" per row.
4654
+ expect(css).toMatch(
4655
+ /\.thread-group\s*\{[^}]*--site-thread-marker-ring-inset:\s*2px;\s*--site-thread-marker-ring-width:\s*2px;/,
4656
+ );
4657
+ expect(css).not.toMatch(
4658
+ /\.compose-thread-layout\s*\{[^}]*--site-thread-marker-ring/,
4659
+ );
4660
+ expect(css).not.toMatch(
4661
+ /\.compose-thread-dot::before\s*\{[^}]*--site-thread-(dot-ring|marker-ring)/,
4662
+ );
4663
+ // The per-dot border and the size-based hero markers stay gone: a border
4664
+ // eats into the fill instead of sitting outside it, and a bigger dot
4665
+ // repeats what the full card beside it already says.
4666
+ for (const dead of [
4667
+ "--site-thread-marker-border-width",
4668
+ "--site-thread-marker-hero-size",
4669
+ "--site-thread-marker-hero-border-width",
4670
+ ]) {
4671
+ expect(css).not.toContain(dead);
4672
+ }
4673
+ expect(css).not.toMatch(/\.compose-thread-dot(::before)?\s*\{[^}]*border:/);
3227
4674
  });
3228
4675
 
3229
- it("keeps reply compose tools inside the constrained editor surface", () => {
4676
+ it("punches the dot's gap with the surface colour on both rails", () => {
3230
4677
  const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
3231
4678
 
4679
+ // Compose stops at the punch-out; reading layers its ring inside the same
4680
+ // radius, so the outermost layer — the one that breaks the rail line — is
4681
+ // still the gap at its full size on both rails.
4682
+ const punchOut =
4683
+ /0 0 0 var\(--site-thread-marker-gap\)\s*var\(--site-thread-marker-surface\);/;
3232
4684
  expect(css).toMatch(
3233
- /\.compose-editor-row\s*>\s*jant-compose-editor\s*\{[\s\S]*align-self:\s*stretch;[\s\S]*overflow:\s*hidden;/,
4685
+ new RegExp(
4686
+ `\\.compose-thread-dot::before\\s*\\{[^}]*box-shadow:\\s*${punchOut.source}`,
4687
+ ),
3234
4688
  );
3235
- expect(css).toMatch(/\.compose-tools-row\s*\{[\s\S]*flex-shrink:\s*0;/);
3236
4689
  expect(css).toMatch(
3237
- /\.compose-editor-row\s+\.compose-attachments-dock\s*\{[\s\S]*max-height:\s*min\(240px,\s*34dvh\);[\s\S]*overflow-y:\s*auto;/,
4690
+ new RegExp(`\\.thread-item::before\\s*\\{[^}]*${punchOut.source}`),
3238
4691
  );
4692
+ // Reading's ring: a band of surface, then the accent tint, both drawn as
4693
+ // spread inside the punch. A border would eat the fill instead.
3239
4694
  expect(css).toMatch(
3240
- /\.compose-reply-compose-layout\s+\.compose-editor-row\s+\.compose-attachments-dock\s*\{[\s\S]*--compose-reply-attachment-width:\s*96px;[\s\S]*--compose-reply-attachment-height:\s*72px;[\s\S]*max-height:\s*min\(116px,\s*18dvh\);/,
4695
+ /\.thread-item::before\s*\{[^}]*box-shadow:\s*0 0 0 var\(--site-thread-marker-ring-inset\)\s*var\(--site-thread-marker-surface\),\s*0 0 0\s*calc\(\s*var\(--site-thread-marker-ring-inset\) \+\s*var\(--site-thread-marker-ring-width\)\s*\)\s*var\(--site-thread-dot-ring\),/,
3241
4696
  );
4697
+ expect(css).not.toMatch(/\.thread-item::before\s*\{[^}]*border:\s/);
4698
+ });
4699
+
4700
+ it("marks the post a view is about with an accent fill, not a bigger dot", () => {
4701
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4702
+
4703
+ // Feed hero, curated highlight and the opened detail post share one rule,
4704
+ // matching compose's `.is-current`. Size is not a second signal.
3242
4705
  expect(css).toMatch(
3243
- /\.compose-reply-compose-layout[\s\S]*\.compose-attachment:only-child[\s\S]*\.compose-attachment-img,[\s\S]*\.compose-reply-compose-layout[\s\S]*\.compose-attachment:not\(:only-child\)[\s\S]*\.compose-attachment-img\s*\{[\s\S]*height:\s*var\(--compose-reply-attachment-height\);[\s\S]*object-fit:\s*cover;/,
4706
+ /\.thread-item-hero::before,\s*\.thread-item-curated::before,\s*\.thread-item-current::before\s*\{\s*background-color:\s*var\(--site-thread-marker-current\);\s*\}/,
3244
4707
  );
4708
+ // Both rails take the current-post colour from one token, and that token is
4709
+ // a tint — the raw accent made a 7px dot the loudest thing on the page.
3245
4710
  expect(css).toMatch(
3246
- /\.compose-reply-compose-layout\s+\.compose-thread-post-header\s*\+\s*\.compose-body\s*\{[\s\S]*padding-top:\s*12px;/,
4711
+ /\.compose-editor-row\.is-current \.compose-thread-dot::before\s*\{\s*background-color:\s*var\(--site-thread-marker-current\);/,
3247
4712
  );
3248
4713
  expect(css).toMatch(
3249
- /@media \(max-width:\s*760px\),\s*\(hover:\s*none\) and \(pointer:\s*coarse\)\s*\{[\s\S]*\.compose-thread-layout\s*\{[\s\S]*padding-top:\s*0\.75rem;[\s\S]*\.compose-thread-layout::before\s*\{[\s\S]*top:\s*0\.75rem;/,
4714
+ /--site-thread-marker-current:\s*color-mix\(\s*in srgb,\s*var\(--site-accent\) 90%,\s*var\(--site-threadline\)\s*\);/,
3250
4715
  );
3251
- expect(css).toMatch(
3252
- /\.compose-dialog,[\s\S]*\.compose-page-shell\s*>\s*jant-compose-dialog\s*\{[\s\S]*--compose-quote-input-size:\s*var\(--type-content-subtitle\);[\s\S]*--compose-quote-input-leading:\s*1\.32;/,
4716
+ expect(css).not.toMatch(
4717
+ /\.thread-item-current::before\s*\{[^}]*var\(--site-accent\)[^-]/,
3253
4718
  );
4719
+ // Every dot's geometry now derives from the shared token.
3254
4720
  expect(css).toMatch(
3255
- /\.compose-quote-text\s*\{[\s\S]*font-size:\s*var\(--compose-quote-input-size\);[\s\S]*line-height:\s*var\(--compose-quote-input-leading\);/,
4721
+ /\.thread-item::before\s*\{[^}]*width:\s*var\(--site-thread-marker-size\);/,
3256
4722
  );
4723
+ expect(css).not.toMatch(/\.thread-item::before\s*\{[^}]*width:\s*\d+px;/);
4724
+ });
4725
+
4726
+ it("centres every rail child on the line by giving them a shared track", () => {
4727
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4728
+
4729
+ // The line's x is derived from the track, never from a dot's own size — a
4730
+ // hardcoded half-marker is what put the line down the dot's edge before.
3257
4731
  expect(css).toMatch(
3258
- /\.compose-reply-compose-layout\s*\{[\s\S]*--compose-title-input-size:\s*var\(--type-content-subtitle\);[\s\S]*--compose-quote-input-size:\s*calc\(var\(--type-content-body\) \* 1\.06\);[\s\S]*--compose-quote-input-leading:\s*1\.42;[\s\S]*--compose-inline-input-size:\s*var\(--type-base\);/,
4732
+ /--compose-thread-rail-left:\s*calc\(\s*var\(--compose-thread-padding-left\) \+ var\(--compose-thread-rail-width\) \/ 2\s*\);/,
3259
4733
  );
4734
+ expect(css).not.toMatch(/--compose-thread-rail-left:[^;]*\+ \d/);
4735
+
4736
+ // Post dot: fills the track, centres the circle in it.
3260
4737
  expect(css).toMatch(
3261
- /\.compose-reply-compose-layout\s+\.compose-quote-wrap\s*\{[\s\S]*margin-top:\s*0\.4rem;[\s\S]*padding:\s*24px 18px 18px;[\s\S]*border-radius:\s*0\.95rem;/,
4738
+ /\.compose-thread-dot\s*\{[\s\S]*justify-content:\s*center;[\s\S]*width:\s*var\(--compose-thread-rail-width\);/,
3262
4739
  );
4740
+ // "Add to thread" placeholder: fills the track outright, so no offset.
3263
4741
  expect(css).toMatch(
3264
- /\.compose-reply-compose-layout\s+\.compose-link-url-wrap\s*\{[\s\S]*margin-top:\s*0\.4rem;/,
4742
+ /\.compose-thread-add-dot\s*\{[\s\S]*width:\s*var\(--compose-thread-rail-width\);[\s\S]*height:\s*var\(--compose-thread-rail-width\);/,
3265
4743
  );
4744
+ expect(css).not.toMatch(/\.compose-thread-add-dot\s*\{[^}]*margin-left:/);
4745
+ // Reply meta hangs off the same track as the rows above it.
3266
4746
  expect(css).toMatch(
3267
- /\.compose-reply-compose-layout\s+\.compose-quote-text\s*\{[\s\S]*min-height:\s*6\.5rem;[\s\S]*padding-top:\s*0\.35rem;/,
4747
+ /\.compose-reply-meta\s*\{[\s\S]*var\(--compose-thread-gap\) \+\s*var\(--compose-thread-rail-width\)/,
3268
4748
  );
4749
+ });
4750
+
4751
+ it("accents the dot of the post holding the cursor, not every editor row", () => {
4752
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4753
+
4754
+ // Which selector carries the highlight — the colour itself is pinned by
4755
+ // "marks the post a view is about…", so this stays token-agnostic.
3269
4756
  expect(css).toMatch(
3270
- /\.compose-reply-compose-layout\s+\.compose-tiptap-body\s+\.tiptap\s*\{[\s\S]*font-size:\s*var\(--type-content-body\);/,
4757
+ /\.compose-editor-row\.is-current \.compose-thread-dot::before\s*\{\s*background-color:/,
4758
+ );
4759
+ // The old rule lit every post in a thread at once.
4760
+ expect(css).not.toMatch(
4761
+ /\.compose-editor-row \.compose-thread-dot\s*\{\s*background-color:/,
4762
+ );
4763
+ });
4764
+
4765
+ it("moves the current-post marker as focus moves between thread posts", async () => {
4766
+ const el = await createElement();
4767
+ el._threadItems = [
4768
+ { id: "thread-1", format: "note" },
4769
+ { id: "thread-2", format: "note" },
4770
+ { id: "thread-3", format: "note" },
4771
+ ];
4772
+ await flushUpdates(el);
4773
+
4774
+ const currentIndexes = () =>
4775
+ Array.from(el.querySelectorAll<HTMLElement>(".compose-thread-post"))
4776
+ .map((row, index) =>
4777
+ row.classList.contains("is-current") ? index : -1,
4778
+ )
4779
+ .filter((index) => index >= 0);
4780
+
4781
+ // Exactly one at a time — the old rule accented all three.
4782
+ expect(currentIndexes()).toEqual([0]);
4783
+
4784
+ const second = requireElement(
4785
+ el.querySelector<HTMLElement>(
4786
+ '.compose-thread-post[data-thread-index="1"]',
4787
+ ),
4788
+ "expected the second thread post",
3271
4789
  );
4790
+ second.dispatchEvent(new Event("focusin", { bubbles: true }));
4791
+ await flushUpdates(el);
4792
+
4793
+ expect(currentIndexes()).toEqual([1]);
3272
4794
  });
3273
4795
 
3274
4796
  it("keeps passive footnote references quiet until the editor selects them", () => {
@@ -3304,6 +4826,7 @@ describe("JantComposeDialog", () => {
3304
4826
  alt: "",
3305
4827
  error: null,
3306
4828
  posterUrl: null,
4829
+ remoteUrl: null,
3307
4830
  summary: null,
3308
4831
  chars: null,
3309
4832
  },
@@ -3346,6 +4869,7 @@ describe("JantComposeDialog", () => {
3346
4869
  alt: "",
3347
4870
  error: null,
3348
4871
  posterUrl: null,
4872
+ remoteUrl: null,
3349
4873
  summary: null,
3350
4874
  chars: null,
3351
4875
  },
@@ -3401,6 +4925,7 @@ describe("JantComposeDialog", () => {
3401
4925
  alt: "A test image",
3402
4926
  error: null,
3403
4927
  posterUrl: null,
4928
+ remoteUrl: null,
3404
4929
  summary: null,
3405
4930
  chars: null,
3406
4931
  },
@@ -3471,6 +4996,7 @@ describe("JantComposeDialog", () => {
3471
4996
  alt: "Alt for pending",
3472
4997
  error: null,
3473
4998
  posterUrl: null,
4999
+ remoteUrl: null,
3474
5000
  summary: null,
3475
5001
  chars: null,
3476
5002
  },
@@ -3546,7 +5072,97 @@ describe("JantComposeDialog", () => {
3546
5072
  );
3547
5073
  });
3548
5074
 
3549
- // ── Close confirmation ─────────────────────────────────────────────
5075
+ // ── Leaving compose ────────────────────────────────────────────────
5076
+
5077
+ it("names the dialog for assistive tech now that no title is drawn", async () => {
5078
+ // The visible title carried the dialog's accessible name; it moves to an
5079
+ // attribute rather than disappearing with the row.
5080
+ const dialog = document.createElement("dialog");
5081
+ document.body.appendChild(dialog);
5082
+ const el = document.createElement(
5083
+ "jant-compose-dialog",
5084
+ ) as JantComposeDialog;
5085
+ el.collections = collections;
5086
+ el.labels = labels;
5087
+ dialog.appendChild(el);
5088
+ await flushUpdates(el);
5089
+
5090
+ expect(dialog.getAttribute("aria-label")).toBe(labels.composeDialogLabel);
5091
+
5092
+ // Tear the dialog down here: happy-dom throws when `body.innerHTML = ""`
5093
+ // in the shared beforeEach removes one it did not itself create.
5094
+ el.remove();
5095
+ dialog.remove();
5096
+ });
5097
+
5098
+ it("closes from the × in the post header row", async () => {
5099
+ const el = await createElement();
5100
+ await flushUpdates(el);
5101
+ const requestCloseSpy = vi.spyOn(el, "requestClose");
5102
+
5103
+ const closeBtn = requireElement(
5104
+ el.querySelector<HTMLButtonElement>(
5105
+ ".compose-thread-post-header .compose-close-btn",
5106
+ ),
5107
+ "expected close button in the post header",
5108
+ );
5109
+ expect(closeBtn.getAttribute("aria-label")).toBe(labels.cancel);
5110
+
5111
+ closeBtn.click();
5112
+ await flushUpdates(el);
5113
+
5114
+ expect(requestCloseSpy).toHaveBeenCalledTimes(1);
5115
+ });
5116
+
5117
+ it("hands the header slot back to per-post remove in a thread, and moves the exit into the options panel", async () => {
5118
+ const el = await createElement();
5119
+ el._threadItems = [
5120
+ { id: "thread-1", format: "note" },
5121
+ { id: "thread-2", format: "note" },
5122
+ ];
5123
+ await flushUpdates(el);
5124
+
5125
+ // Each post owns its × for removing itself, so no close button competes.
5126
+ expect(el.querySelector(".compose-close-btn")).toBeNull();
5127
+ expect(
5128
+ el.querySelectorAll(".compose-thread-post-remove").length,
5129
+ ).toBeGreaterThan(0);
5130
+
5131
+ await openPublishPanel(el);
5132
+ const closeRow = requireElement(
5133
+ Array.from(
5134
+ el.querySelectorAll<HTMLButtonElement>(
5135
+ ".compose-publish-panel .compose-sheet-row",
5136
+ ),
5137
+ ).find(
5138
+ (row) =>
5139
+ row.querySelector(".compose-sheet-title")?.textContent?.trim() ===
5140
+ labels.closeCompose,
5141
+ ) ?? null,
5142
+ "expected a close row in the options panel",
5143
+ );
5144
+
5145
+ const requestCloseSpy = vi.spyOn(el, "requestClose");
5146
+ closeRow.click();
5147
+ await flushUpdates(el);
5148
+
5149
+ expect(el._showPublishPanel).toBe(false);
5150
+ expect(requestCloseSpy).toHaveBeenCalledTimes(1);
5151
+ });
5152
+
5153
+ it("keeps the exit out of the options panel when the × already offers one", async () => {
5154
+ const el = await createElement();
5155
+ await openPublishPanel(el);
5156
+
5157
+ const titles = Array.from(
5158
+ el.querySelectorAll<HTMLElement>(
5159
+ ".compose-publish-panel .compose-sheet-title",
5160
+ ),
5161
+ ).map((n) => n.textContent?.trim());
5162
+
5163
+ expect(titles).not.toContain(labels.closeCompose);
5164
+ expect(titles).toContain("Drafts");
5165
+ });
3550
5166
 
3551
5167
  it("requestClose on empty form closes immediately without confirmation", async () => {
3552
5168
  const el = await createElement();
@@ -5008,9 +6624,12 @@ describe("JantComposeDialog", () => {
5008
6624
  el._format = "note";
5009
6625
  await el.updateComplete;
5010
6626
 
5011
- // Format switcher should be visible (not "Edit post" title)
6627
+ // Format switcher should be visible, and the submit label says this is a
6628
+ // new post rather than an edit.
5012
6629
  expect(el.querySelector(".compose-segmented")).not.toBeNull();
5013
- expect(el.querySelector(".compose-dialog-title")).toBeNull();
6630
+ expect(el.querySelector(".compose-publish-main")?.textContent?.trim()).toBe(
6631
+ "Post",
6632
+ );
5014
6633
 
5015
6634
  // Button should say "Post", not "Done"
5016
6635
  const postBtn = requireElement(
@@ -5095,11 +6714,94 @@ describe("JantComposeDialog", () => {
5095
6714
  expect((receivedDetail as unknown as ComposeSubmitDetail).editPostId).toBe(
5096
6715
  "draft789",
5097
6716
  );
6717
+ expect(
6718
+ (receivedDetail as unknown as ComposeSubmitDetail).draftSourceId,
6719
+ ).toBe("draft789");
5098
6720
  expect((receivedDetail as unknown as ComposeSubmitDetail).status).toBe(
5099
6721
  "published",
5100
6722
  );
5101
6723
  });
5102
6724
 
6725
+ it("opens a thread draft without deleting it and preserves its draft identity", async () => {
6726
+ const rootId = "pst_draft_root";
6727
+ const replyId = "pst_draft_reply";
6728
+ const requests: Array<{ url: string; method: string }> = [];
6729
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
6730
+ const url = String(input);
6731
+ requests.push({ url, method: init?.method ?? "GET" });
6732
+
6733
+ if (url === `/api/posts/${rootId}`) {
6734
+ return new Response(
6735
+ JSON.stringify({
6736
+ id: rootId,
6737
+ threadId: rootId,
6738
+ format: "note",
6739
+ status: "draft",
6740
+ slug: "draft-thread",
6741
+ title: "Draft thread",
6742
+ body: null,
6743
+ attachments: [],
6744
+ collectionIds: [],
6745
+ }),
6746
+ { headers: { "Content-Type": "application/json" } },
6747
+ );
6748
+ }
6749
+
6750
+ if (url === "/api/posts?status=draft&limit=50") {
6751
+ return new Response(
6752
+ JSON.stringify({
6753
+ posts: [
6754
+ {
6755
+ id: replyId,
6756
+ threadId: rootId,
6757
+ replyToId: rootId,
6758
+ format: "quote",
6759
+ status: "draft",
6760
+ quoteText: "Draft reply",
6761
+ attachments: [],
6762
+ },
6763
+ ],
6764
+ }),
6765
+ { headers: { "Content-Type": "application/json" } },
6766
+ );
6767
+ }
6768
+
6769
+ throw new Error(`Unexpected fetch: ${url}`);
6770
+ });
6771
+
6772
+ const el = await createElement();
6773
+ await el.openDraft(rootId);
6774
+ await flushUpdates(el);
6775
+
6776
+ expect(el._draftSourceId).toBe(rootId);
6777
+ expect(el.querySelectorAll("jant-compose-editor")).toHaveLength(2);
6778
+ expect(requests).toEqual([
6779
+ { url: `/api/posts/${rootId}`, method: "GET" },
6780
+ { url: "/api/posts?status=draft&limit=50", method: "GET" },
6781
+ ]);
6782
+
6783
+ let receivedDetail: ComposeSubmitDetail | null = null;
6784
+ el.addEventListener("jant:compose-submit-deferred", (event) => {
6785
+ receivedDetail = (event as CustomEvent<ComposeSubmitDetail>).detail;
6786
+ });
6787
+ requireElement(
6788
+ el.querySelector<HTMLButtonElement>(".compose-publish-main"),
6789
+ "expected publish button",
6790
+ ).click();
6791
+
6792
+ expect(receivedDetail).not.toBeNull();
6793
+ expect(
6794
+ (receivedDetail as unknown as ComposeSubmitDetail).draftSourceId,
6795
+ ).toBe(rootId);
6796
+ expect((receivedDetail as unknown as ComposeSubmitDetail).editPostId).toBe(
6797
+ rootId,
6798
+ );
6799
+ expect(
6800
+ (receivedDetail as unknown as ComposeSubmitDetail).threadPosts,
6801
+ ).toHaveLength(2);
6802
+ expect(requests.every((request) => request.method === "GET")).toBe(true);
6803
+ });
6804
+
5103
6805
  it("draft button confirm save dispatches draft then opens drafts panel", async () => {
5104
6806
  const el = await createElement();
5105
6807
  const editor = requireElement(
@@ -5129,13 +6831,10 @@ describe("JantComposeDialog", () => {
5129
6831
  receivedDetail = (event as CustomEvent<ComposeSubmitDetail>).detail;
5130
6832
  });
5131
6833
 
5132
- // Click draft button → confirm panel
5133
- const draftBtn = requireElement(
5134
- el.querySelector<HTMLButtonElement>(".compose-dialog-header-btn"),
5135
- "expected draft button",
5136
- );
5137
- draftBtn.click();
5138
- await el.updateComplete;
6834
+ // Click the drafts row in the options panel → confirm panel
6835
+ await openPublishPanel(el);
6836
+ requireElement(draftsRow(el), "expected drafts row").click();
6837
+ await flushUpdates(el);
5139
6838
  expect(el._confirmPanelOpen).toBe(true);
5140
6839
 
5141
6840
  // Click "Save"
@@ -5200,13 +6899,10 @@ describe("JantComposeDialog", () => {
5200
6899
  submitFired = true;
5201
6900
  });
5202
6901
 
5203
- // Click draft button → confirm panel
5204
- const draftBtn = requireElement(
5205
- el.querySelector<HTMLButtonElement>(".compose-dialog-header-btn"),
5206
- "expected draft button",
5207
- );
5208
- draftBtn.click();
5209
- await el.updateComplete;
6902
+ // Click the drafts row in the options panel → confirm panel
6903
+ await openPublishPanel(el);
6904
+ requireElement(draftsRow(el), "expected drafts row").click();
6905
+ await flushUpdates(el);
5210
6906
 
5211
6907
  // Click "Don't save"
5212
6908
  requireElement(
@@ -5248,6 +6944,7 @@ describe("JantComposeDialog", () => {
5248
6944
  alt: "",
5249
6945
  error: null,
5250
6946
  posterUrl: null,
6947
+ remoteUrl: null,
5251
6948
  summary: null,
5252
6949
  chars: null,
5253
6950
  },