@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
@@ -0,0 +1,135 @@
1
+ /**
2
+ * In-place refresh of a rendered Post.
3
+ *
4
+ * Reply submission, draft publishing, and anything else that changes a Post
5
+ * server-side needs the same thing afterwards: re-render exactly the surface
6
+ * the Post is shown on — a thread page, a feed item, or a standalone card —
7
+ * without a full page load.
8
+ */
9
+
10
+ import { hydratePartial } from "./hydrate-partial.js";
11
+ import { getReplyRefreshTarget } from "./compose-launch.js";
12
+
13
+ async function fetchPartialHtml(path: string): Promise<string | null> {
14
+ const res = await fetch(path, {
15
+ headers: { Accept: "text/html" },
16
+ });
17
+ if (!res.ok) return null;
18
+ return res.text();
19
+ }
20
+
21
+ export async function refreshTimelineThreadView(
22
+ threadRootId: string,
23
+ ): Promise<boolean> {
24
+ try {
25
+ const timelineItem = document.querySelector<HTMLElement>(
26
+ `[data-timeline-item][data-thread-root-id="${threadRootId}"]`,
27
+ );
28
+ const content = timelineItem?.querySelector<HTMLElement>(
29
+ "[data-timeline-item-content]",
30
+ );
31
+ if (!content) return false;
32
+
33
+ const html = await fetchPartialHtml(
34
+ `/_/timeline-item/${encodeURIComponent(threadRootId)}`,
35
+ );
36
+ if (!html) return false;
37
+
38
+ content.innerHTML = html;
39
+ // Swapped-in markup carries interactions whose per-element setup only runs
40
+ // on DOMContentLoaded (thread "Show more" toggle, feed video autoplay, audio
41
+ // waveform); re-initialize them or they stay inert until a full reload.
42
+ hydratePartial(content);
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ export async function refreshPostCardView(postId: string): Promise<boolean> {
50
+ try {
51
+ const timelineItem = document
52
+ .querySelector<HTMLElement>(`article[data-post-id="${postId}"]`)
53
+ ?.closest<HTMLElement>("[data-timeline-item]");
54
+ const html = await fetchPartialHtml(
55
+ `/_/post-card/${encodeURIComponent(postId)}`,
56
+ );
57
+ if (!html) return false;
58
+
59
+ if (timelineItem) {
60
+ const content = timelineItem.querySelector<HTMLElement>(
61
+ "[data-timeline-item-content]",
62
+ );
63
+ if (!content) return false;
64
+ content.innerHTML = html;
65
+ hydratePartial(content);
66
+ return true;
67
+ }
68
+
69
+ const article = document.querySelector<HTMLElement>(
70
+ `article[data-post-id="${postId}"]`,
71
+ );
72
+ if (!article) return false;
73
+
74
+ article.outerHTML = html;
75
+ // outerHTML detaches `article`; re-query the replacement to hydrate it.
76
+ const nextArticle = document.querySelector<HTMLElement>(
77
+ `article[data-post-id="${postId}"]`,
78
+ );
79
+ if (nextArticle) hydratePartial(nextArticle);
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ export async function refreshPostPageView(postId: string): Promise<boolean> {
87
+ try {
88
+ const container = document.querySelector<HTMLElement>(
89
+ `[data-post-view][data-post-view-id="${postId}"]`,
90
+ );
91
+ if (!container) return false;
92
+
93
+ const html = await fetchPartialHtml(
94
+ `/_/post-view/${encodeURIComponent(postId)}`,
95
+ );
96
+ if (!html) return false;
97
+
98
+ container.outerHTML = html;
99
+ // outerHTML detaches `container`; re-query the replacement to hydrate it
100
+ // (see refreshTimelineThreadView).
101
+ const next = document.querySelector<HTMLElement>(
102
+ `[data-post-view][data-post-view-id="${postId}"]`,
103
+ );
104
+ if (next) hydratePartial(next);
105
+ return true;
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Re-renders whichever surface an article belongs to, reusing the same
113
+ * targeting the reply flow uses.
114
+ *
115
+ * @param article - The rendered `article[data-post]` element to refresh
116
+ * @returns Whether a surface was found and re-rendered
117
+ * @example
118
+ * ```ts
119
+ * if (!(await refreshArticleView(article))) globalThis.location.reload();
120
+ * ```
121
+ */
122
+ export async function refreshArticleView(
123
+ article: HTMLElement,
124
+ ): Promise<boolean> {
125
+ const target = getReplyRefreshTarget(article);
126
+ if (!target) return false;
127
+
128
+ if (target.kind === "timeline-item") {
129
+ return refreshTimelineThreadView(target.id);
130
+ }
131
+ if (target.kind === "post-view") {
132
+ return refreshPostPageView(target.id);
133
+ }
134
+ return refreshPostCardView(target.id);
135
+ }
@@ -0,0 +1,121 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { Editor } from "@tiptap/core";
5
+ import { createMarkdownContentExtensions } from "../../../lib/markdown-manager.js";
6
+ import { LinkInputRules } from "../link-input-rules.js";
7
+
8
+ const editors: Editor[] = [];
9
+
10
+ function createEditor(): Editor {
11
+ const element = document.createElement("div");
12
+ document.body.appendChild(element);
13
+
14
+ const editor = new Editor({
15
+ element,
16
+ extensions: [...createMarkdownContentExtensions(), LinkInputRules],
17
+ content: "<p></p>",
18
+ });
19
+
20
+ editors.push(editor);
21
+ return editor;
22
+ }
23
+
24
+ function type(editor: Editor, text: string): void {
25
+ const view = editor.view;
26
+ for (const character of text) {
27
+ const { from, to } = view.state.selection;
28
+ const handled = view.someProp("handleTextInput", (handler) =>
29
+ handler(view, from, to, character, () =>
30
+ view.state.tr.insertText(character, from, to),
31
+ ),
32
+ );
33
+ if (!handled) {
34
+ view.dispatch(view.state.tr.insertText(character, from, to));
35
+ }
36
+ }
37
+ }
38
+
39
+ afterEach(() => {
40
+ while (editors.length > 0) {
41
+ editors.pop()?.destroy();
42
+ }
43
+ document.body.innerHTML = "";
44
+ });
45
+
46
+ describe("LinkInputRules", () => {
47
+ it("converts a typed sms Markdown link into a link mark", () => {
48
+ const editor = createEditor();
49
+
50
+ type(editor, "[xxx](sms:xxxxx@xx.com)");
51
+
52
+ expect(editor.getJSON().content?.[0]).toMatchObject({
53
+ type: "paragraph",
54
+ content: [
55
+ {
56
+ type: "text",
57
+ text: "xxx",
58
+ marks: [
59
+ {
60
+ type: "link",
61
+ attrs: {
62
+ href: "sms:xxxxx@xx.com",
63
+ },
64
+ },
65
+ ],
66
+ },
67
+ ],
68
+ });
69
+ });
70
+
71
+ it.each(["tel:+15551234567", "sms:+15551234567"])(
72
+ "auto-links a bare %s action URL",
73
+ (href) => {
74
+ const editor = createEditor();
75
+
76
+ type(editor, `${href} `);
77
+
78
+ expect(editor.getJSON().content?.[0]?.content?.[0]).toMatchObject({
79
+ type: "text",
80
+ text: href,
81
+ marks: [
82
+ {
83
+ type: "link",
84
+ attrs: { href },
85
+ },
86
+ ],
87
+ });
88
+ },
89
+ );
90
+
91
+ it("leaves executable Markdown links as typed text", () => {
92
+ const editor = createEditor();
93
+
94
+ type(editor, "[unsafe](javascript:alert(1))");
95
+
96
+ expect(editor.state.doc.textContent).toBe("[unsafe](javascript:alert(1))");
97
+ expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"link"');
98
+ });
99
+
100
+ it("uses the same action-link policy for TipTap link commands", () => {
101
+ const editor = createEditor();
102
+ editor.commands.insertContent("Message");
103
+ editor.commands.setTextSelection({ from: 1, to: 8 });
104
+
105
+ expect(editor.commands.setLink({ href: "sms:xxxxx@xx.com" })).toBe(true);
106
+ expect(editor.getJSON().content?.[0]?.content?.[0]).toMatchObject({
107
+ marks: [
108
+ {
109
+ type: "link",
110
+ attrs: { href: "sms:xxxxx@xx.com" },
111
+ },
112
+ ],
113
+ });
114
+
115
+ editor.commands.unsetLink();
116
+ expect(editor.commands.setLink({ href: "javascript:alert(1)" })).toBe(
117
+ false,
118
+ );
119
+ expect(JSON.stringify(editor.getJSON())).not.toContain("javascript:");
120
+ });
121
+ });
@@ -1,5 +1,5 @@
1
1
  import { Extension, InputRule } from "@tiptap/core";
2
- import { sanitizeUrl } from "../../lib/url.js";
2
+ import { sanitizeRichTextHref } from "../../lib/url.js";
3
3
 
4
4
  // The URL group uses [^()\s] (single char) instead of [^()\s]+ to avoid
5
5
  // catastrophic backtracking from nested quantifiers when the regex fails
@@ -15,7 +15,7 @@ const MARKDOWN_LINK_INPUT_REGEX = new RegExp(
15
15
  const MARKDOWN_LINK_SPACE_REGEX = new RegExp(
16
16
  MARKDOWN_LINK_PATTERN.source + "\\s$",
17
17
  );
18
- const BARE_URL_INPUT_REGEX = /((?:https?:\/\/|mailto:)[^\s<]+)\s$/;
18
+ const BARE_URL_INPUT_REGEX = /((?:https?:\/\/|mailto:|tel:|sms:)[^\s<]+)\s$/i;
19
19
 
20
20
  export const LinkInputRules = Extension.create({
21
21
  name: "linkInputRules",
@@ -36,7 +36,7 @@ export const LinkInputRules = Extension.create({
36
36
  trailingChar: string,
37
37
  ) {
38
38
  const label = match[1]?.trim();
39
- const href = sanitizeUrl(match[2] ?? "");
39
+ const href = sanitizeRichTextHref(match[2] ?? "");
40
40
 
41
41
  if (!label || !href) {
42
42
  return null;
@@ -56,7 +56,7 @@ export const LinkInputRules = Extension.create({
56
56
  new InputRule({
57
57
  find: BARE_URL_INPUT_REGEX,
58
58
  handler: ({ state, range, match }) => {
59
- const href = sanitizeUrl(match[1] ?? "");
59
+ const href = sanitizeRichTextHref(match[1] ?? "");
60
60
 
61
61
  if (!href) {
62
62
  return null;
@@ -20,8 +20,40 @@ interface QueuedToast {
20
20
 
21
21
  export const QUEUED_TOAST_STORAGE_KEY = "jant.pendingToast";
22
22
 
23
- /** Ensure the toast container is in the top layer (above <dialog> etc.) */
23
+ function openModalDialogs(): HTMLDialogElement[] {
24
+ const open = [
25
+ ...document.querySelectorAll<HTMLDialogElement>("dialog[open]"),
26
+ ];
27
+ try {
28
+ const modal = open.filter((dialog) => dialog.matches(":modal"));
29
+ if (modal.length > 0) return modal;
30
+ } catch {
31
+ // ":modal" unsupported — treat every open dialog as potentially modal.
32
+ }
33
+ // Engines without working ":modal" (happy-dom) match nothing above; Jant
34
+ // only opens dialogs via showModal(), so open dialogs are the right proxy.
35
+ return open;
36
+ }
37
+
38
+ /**
39
+ * A modal <dialog> makes everything outside its subtree inert: the popover
40
+ * promotion keeps toasts *painted* above the dialog, but clicks and text
41
+ * selection would still fall through to the dialog. Mounting the container
42
+ * inside the topmost open modal dialog escapes the inert tree; the close
43
+ * watcher moves it back out when that dialog closes.
44
+ */
45
+ function mountOutsideInertTree(container: HTMLElement): void {
46
+ const dialogs = openModalDialogs();
47
+ // Top-layer order isn't queryable; document order matches Jant's dialog
48
+ // nesting (confirm dialogs are descendants of the dialog that opened them).
49
+ const host = dialogs.at(-1) ?? document.body;
50
+ if (container.parentElement !== host) host.appendChild(container);
51
+ }
52
+
53
+ /** Ensure the toast container is interactive and in the top layer (above <dialog> etc.) */
24
54
  function ensureTopLayer(container: HTMLElement): void {
55
+ mountOutsideInertTree(container);
56
+
25
57
  if (typeof container.showPopover !== "function") return;
26
58
 
27
59
  // Re-promote above any modal dialog that was opened after the toast container.
@@ -38,7 +70,21 @@ function ensureTopLayer(container: HTMLElement): void {
38
70
  }
39
71
 
40
72
  function getToastContainer(): HTMLElement | null {
41
- return document.getElementById("toast-container");
73
+ const existing = document.getElementById("toast-container");
74
+ if (existing) return existing;
75
+ if (!document.body) return null;
76
+
77
+ // The container can vanish with a modal dialog it was mounted into if that
78
+ // dialog is removed from the DOM while open. Recreate it so toasts keep
79
+ // working for the rest of the page's lifetime.
80
+ const container = document.createElement("div");
81
+ container.id = "toast-container";
82
+ container.className = "toast-container";
83
+ container.setAttribute("popover", "manual");
84
+ container.setAttribute("aria-live", "polite");
85
+ container.setAttribute("aria-relevant", "additions text");
86
+ document.body.appendChild(container);
87
+ return container;
42
88
  }
43
89
 
44
90
  function canUseSessionStorage(): boolean {
@@ -92,12 +138,9 @@ const TOAST_ICONS = {
92
138
  '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6M9 9l6 6"/></svg>',
93
139
  };
94
140
 
95
- const COPY_ICON =
96
- '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
97
- const CHECK_ICON =
98
- '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path d="M20 6 9 17l-5-5"/></svg>';
99
141
  const TOAST_DURATION_MS = 3000;
100
- const ACTION_TOAST_DURATION_MS = 8000;
142
+ const ACTION_TOAST_DURATION_MS = 5000;
143
+ const MIN_RESUME_MS = 1000;
101
144
 
102
145
  /** Build toast inner content using safe DOM APIs (icon is trusted, text uses textContent). */
103
146
  function setToastContent(
@@ -117,21 +160,62 @@ function setToastContent(
117
160
  a.textContent = action.label;
118
161
  toast.appendChild(a);
119
162
  }
120
- if (type === "error" && navigator.clipboard) {
121
- const btn = document.createElement("button");
122
- btn.className = "toast-copy";
123
- btn.setAttribute("aria-label", "Copy error message");
124
- btn.innerHTML = COPY_ICON;
125
- btn.addEventListener("click", () => {
126
- navigator.clipboard.writeText(message).then(() => {
127
- btn.innerHTML = CHECK_ICON;
128
- setTimeout(() => {
129
- btn.innerHTML = COPY_ICON;
130
- }, 1500);
131
- });
132
- });
133
- toast.appendChild(btn);
134
- }
163
+ }
164
+
165
+ /**
166
+ * Auto-dismiss a toast after `duration`, pausing while the pointer hovers it
167
+ * or focus is inside it — so the message can be read, selected, and copied.
168
+ * On leave, the remaining time resumes (at least MIN_RESUME_MS).
169
+ */
170
+ function scheduleAutoDismiss(toast: HTMLElement, duration: number): void {
171
+ let remaining = duration;
172
+ let startedAt = 0;
173
+ let timer: ReturnType<typeof setTimeout> | null = null;
174
+ let hovered = false;
175
+ let focused = false;
176
+
177
+ const pause = () => {
178
+ if (timer === null) return;
179
+ clearTimeout(timer);
180
+ timer = null;
181
+ remaining = Math.max(remaining - (Date.now() - startedAt), MIN_RESUME_MS);
182
+ };
183
+
184
+ const resume = () => {
185
+ if (timer !== null) return;
186
+ startedAt = Date.now();
187
+ timer = setTimeout(() => {
188
+ toast.classList.add("toast-out");
189
+ toast.addEventListener("animationend", () => toast.remove());
190
+ }, remaining);
191
+ };
192
+
193
+ const sync = () => {
194
+ if (hovered || focused) {
195
+ pause();
196
+ } else {
197
+ resume();
198
+ }
199
+ };
200
+
201
+ toast.addEventListener("pointerenter", () => {
202
+ hovered = true;
203
+ sync();
204
+ });
205
+ toast.addEventListener("pointerleave", () => {
206
+ hovered = false;
207
+ sync();
208
+ });
209
+ toast.addEventListener("focusin", () => {
210
+ focused = true;
211
+ sync();
212
+ });
213
+ toast.addEventListener("focusout", () => {
214
+ focused = false;
215
+ sync();
216
+ });
217
+
218
+ resume();
135
219
  }
136
220
 
137
221
  /**
@@ -157,10 +241,7 @@ export function showToast(message: string, type: ToastType = "success"): void {
157
241
  setToastContent(toast, type, message);
158
242
  container.appendChild(toast);
159
243
 
160
- setTimeout(() => {
161
- toast.classList.add("toast-out");
162
- toast.addEventListener("animationend", () => toast.remove());
163
- }, TOAST_DURATION_MS);
244
+ scheduleAutoDismiss(toast, TOAST_DURATION_MS);
164
245
  }
165
246
 
166
247
  /**
@@ -190,10 +271,7 @@ export function showToastWithAction(
190
271
  setToastContent(toast, type, message, action);
191
272
  container.appendChild(toast);
192
273
 
193
- setTimeout(() => {
194
- toast.classList.add("toast-out");
195
- toast.addEventListener("animationend", () => toast.remove());
196
- }, ACTION_TOAST_DURATION_MS);
274
+ scheduleAutoDismiss(toast, ACTION_TOAST_DURATION_MS);
197
275
  }
198
276
 
199
277
  /**
@@ -284,10 +362,7 @@ export function replaceWithAutoClose(
284
362
  toast.replaceChildren();
285
363
  setToastContent(toast, type, message);
286
364
 
287
- setTimeout(() => {
288
- toast.classList.add("toast-out");
289
- toast.addEventListener("animationend", () => toast.remove());
290
- }, TOAST_DURATION_MS);
365
+ scheduleAutoDismiss(toast, TOAST_DURATION_MS);
291
366
  }
292
367
 
293
368
  /**
@@ -317,10 +392,7 @@ export function replaceWithAutoCloseAction(
317
392
  toast.replaceChildren();
318
393
  setToastContent(toast, type, message, action);
319
394
 
320
- setTimeout(() => {
321
- toast.classList.add("toast-out");
322
- toast.addEventListener("animationend", () => toast.remove());
323
- }, ACTION_TOAST_DURATION_MS);
395
+ scheduleAutoDismiss(toast, ACTION_TOAST_DURATION_MS);
324
396
  }
325
397
 
326
398
  /**
@@ -363,6 +435,25 @@ export function consumeQueuedToast(): boolean {
363
435
  return true;
364
436
  }
365
437
 
438
+ function initDialogCloseWatcher(): void {
439
+ // "close" doesn't bubble; capture reaches it from the document anyway.
440
+ document.addEventListener(
441
+ "close",
442
+ (event) => {
443
+ const dialog = event.target;
444
+ if (!(dialog instanceof HTMLDialogElement)) return;
445
+
446
+ const container = document.getElementById("toast-container");
447
+ if (!container || !dialog.contains(container)) return;
448
+
449
+ // The closed dialog is display:none — move the container back out
450
+ // (into the next open modal dialog, if any) so live toasts stay visible.
451
+ ensureTopLayer(container);
452
+ },
453
+ true,
454
+ );
455
+ }
456
+
366
457
  function initQueuedToastConsumer(): void {
367
458
  const showQueuedToast = () => {
368
459
  consumeQueuedToast();
@@ -378,4 +469,5 @@ function initQueuedToastConsumer(): void {
378
469
  showQueuedToast();
379
470
  }
380
471
 
472
+ initDialogCloseWatcher();
381
473
  initQueuedToastConsumer();