@jant/core 0.5.1 → 0.5.2

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 (35) hide show
  1. package/dist/{app-D_ke5q_u.js → app-CPVwpmb3.js} +35 -15
  2. package/dist/{app-BpyntjN7.js → app-Z83YzveI.js} +1 -1
  3. package/dist/client/.vite/manifest.json +3 -3
  4. package/dist/client/_assets/{client-DkbGkmgp.js → client-8pp1btGZ.js} +1 -1
  5. package/dist/client/_assets/{client-Hxj-LpVt.css → client-CFegXQty.css} +1 -1
  6. package/dist/client/_assets/{client-auth-BRrao4p4.js → client-auth-Ds3SVvGh.js} +79 -62
  7. package/dist/index.js +1 -1
  8. package/dist/node.js +2 -2
  9. package/package.json +1 -1
  10. package/src/client/__tests__/slash-discovery.test.ts +150 -0
  11. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1 -0
  12. package/src/client/components/__tests__/jant-compose-editor.test.ts +1 -0
  13. package/src/client/components/compose-types.ts +1 -0
  14. package/src/client/components/jant-compose-dialog.ts +8 -0
  15. package/src/client/components/jant-compose-editor.ts +51 -7
  16. package/src/client/slash-discovery-bridge.ts +9 -0
  17. package/src/client/slash-discovery.ts +200 -0
  18. package/src/client/tiptap/slash-commands.ts +5 -0
  19. package/src/client-auth.ts +1 -0
  20. package/src/i18n/locales/public/en.po +5 -0
  21. package/src/i18n/locales/public/en.ts +1 -1
  22. package/src/i18n/locales/public/zh-Hans.po +5 -0
  23. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  24. package/src/i18n/locales/public/zh-Hant.po +5 -0
  25. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  26. package/src/lib/render.tsx +3 -0
  27. package/src/routes/api/__tests__/settings.test.ts +59 -0
  28. package/src/routes/api/settings.ts +19 -0
  29. package/src/routes/pages/new.tsx +6 -0
  30. package/src/styles/ui.css +40 -0
  31. package/src/types/config.ts +5 -0
  32. package/src/types/views.ts +1 -0
  33. package/src/ui/compose/ComposeDialog.tsx +12 -0
  34. package/src/ui/layouts/SiteLayout.tsx +2 -0
  35. package/src/ui/pages/ComposePage.tsx +3 -0
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { _ as url_exports } from "./url-umUptr5z.js";
2
- import { A as NAV_ITEM_TYPES, C as toPostView, D as MAX_MEDIA_ATTACHMENTS, E as FORMATS, M as STATUSES, N as TEXT_ATTACHMENT_CONTENT_FORMATS, O as MAX_PINNED_POSTS, S as toNavItemViews, T as toSearchResultView, _ as createMediaContext, b as toMediaView, f as defaultFeedRenderer, j as SORT_ORDERS, k as MEDIA_KINDS, t as createApp, v as toArchiveGroups, w as toPostViews, x as toNavItemView, y as toArchiveGroupsWithMedia } from "./app-D_ke5q_u.js";
2
+ import { A as NAV_ITEM_TYPES, C as toPostView, D as MAX_MEDIA_ATTACHMENTS, E as FORMATS, M as STATUSES, N as TEXT_ATTACHMENT_CONTENT_FORMATS, O as MAX_PINNED_POSTS, S as toNavItemViews, T as toSearchResultView, _ as createMediaContext, b as toMediaView, f as defaultFeedRenderer, j as SORT_ORDERS, k as MEDIA_KINDS, t as createApp, v as toArchiveGroups, w as toPostViews, x as toNavItemView, y as toArchiveGroupsWithMedia } from "./app-CPVwpmb3.js";
3
3
  import { T as time_exports, a as markdown_exports } from "./export-I9XFTWyO.js";
4
4
  import "./env-CgaH9Mut.js";
5
5
  import "./github-sync-DBAwA3H9.js";
package/dist/node.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import "./url-umUptr5z.js";
2
- import { F as BUILTIN_COLOR_THEMES, I as getPublicAssetBasePath, L as isAssetPath, P as buildThemeStyle, a as resolveDatabaseDialect, c as resolveConfig, d as getFontThemeCssVariables, g as schema_exports, h as createNodeDatabase, i as createSiteService, l as BUILTIN_FONT_THEMES, m as sqliteSchemaBundle, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as pgSchemaBundle, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as getCjkSerifCssVariables } from "./app-D_ke5q_u.js";
2
+ import { F as BUILTIN_COLOR_THEMES, I as getPublicAssetBasePath, L as isAssetPath, P as buildThemeStyle, a as resolveDatabaseDialect, c as resolveConfig, d as getFontThemeCssVariables, g as schema_exports, h as createNodeDatabase, i as createSiteService, l as BUILTIN_FONT_THEMES, m as sqliteSchemaBundle, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as pgSchemaBundle, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as getCjkSerifCssVariables } from "./app-CPVwpmb3.js";
3
3
  import { t as createExportService } from "./export-I9XFTWyO.js";
4
4
  import { b as getSiteResolutionMode, i as getConfiguredSingleSitePathPrefix, l as getEnvString, r as getConfiguredSingleSiteOrigin, x as shouldTrustProxy, y as getPort } from "./env-CgaH9Mut.js";
5
5
  import "./github-sync-DBAwA3H9.js";
@@ -474,7 +474,7 @@ async function createNodeRequestHandler(options) {
474
474
  async function start(env = process.env, app) {
475
475
  const handler = await createNodeRequestHandler({
476
476
  env,
477
- app: async () => app ?? (await import("./app-BpyntjN7.js")).createApp()
477
+ app: async () => app ?? (await import("./app-Z83YzveI.js")).createApp()
478
478
  });
479
479
  const hostname = resolveHost(env);
480
480
  const port = resolvePort(env);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,150 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import {
5
+ __testOnly,
6
+ hideSlashCommandHint,
7
+ markSlashCommandDiscovered,
8
+ scheduleSlashCommandHint,
9
+ } from "../slash-discovery.js";
10
+
11
+ function createEditorHost(discovered = false): HTMLElement {
12
+ document.body.innerHTML = `
13
+ <jant-compose-editor data-slash-command-discovered="${discovered ? "true" : "false"}">
14
+ <div class="compose-tiptap-body"></div>
15
+ <span class="compose-slash-discovery-hint" aria-hidden="true">Type / for commands</span>
16
+ </jant-compose-editor>
17
+ `;
18
+
19
+ return document.querySelector<HTMLElement>(
20
+ "jant-compose-editor",
21
+ ) as HTMLElement;
22
+ }
23
+
24
+ describe("slash discovery", () => {
25
+ beforeEach(() => {
26
+ vi.restoreAllMocks();
27
+ vi.useFakeTimers();
28
+ globalThis.localStorage.clear();
29
+ __testOnly.reset();
30
+ vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: true }));
31
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
32
+ });
33
+
34
+ afterEach(() => {
35
+ vi.useRealTimers();
36
+ vi.unstubAllGlobals();
37
+ });
38
+
39
+ it("shows the hint after the delay and records one exposure per page", () => {
40
+ const host = createEditorHost();
41
+
42
+ scheduleSlashCommandHint(host);
43
+ vi.advanceTimersByTime(__testOnly.SLASH_HINT_DELAY_MS);
44
+
45
+ expect(host.classList.contains(__testOnly.SLASH_HINT_VISIBLE_CLASS)).toBe(
46
+ true,
47
+ );
48
+ expect(__testOnly.readState()).toMatchObject({
49
+ shownCount: 1,
50
+ completed: false,
51
+ });
52
+
53
+ // No auto-fade — the hint stays visible until something explicitly hides it.
54
+ vi.advanceTimersByTime(5000);
55
+ expect(host.classList.contains(__testOnly.SLASH_HINT_VISIBLE_CLASS)).toBe(
56
+ true,
57
+ );
58
+
59
+ hideSlashCommandHint(host);
60
+ expect(host.classList.contains(__testOnly.SLASH_HINT_VISIBLE_CLASS)).toBe(
61
+ false,
62
+ );
63
+
64
+ // Refocusing during the same page load shows the hint again
65
+ // without bumping the persisted shownCount.
66
+ scheduleSlashCommandHint(host);
67
+ vi.advanceTimersByTime(__testOnly.SLASH_HINT_DELAY_MS);
68
+
69
+ expect(host.classList.contains(__testOnly.SLASH_HINT_VISIBLE_CLASS)).toBe(
70
+ true,
71
+ );
72
+ expect(__testOnly.readState().shownCount).toBe(1);
73
+ });
74
+
75
+ it("hides the hint immediately when called", () => {
76
+ const host = createEditorHost();
77
+
78
+ scheduleSlashCommandHint(host);
79
+ vi.advanceTimersByTime(__testOnly.SLASH_HINT_DELAY_MS);
80
+ expect(host.classList.contains(__testOnly.SLASH_HINT_VISIBLE_CLASS)).toBe(
81
+ true,
82
+ );
83
+
84
+ hideSlashCommandHint(host);
85
+ expect(host.classList.contains(__testOnly.SLASH_HINT_VISIBLE_CLASS)).toBe(
86
+ false,
87
+ );
88
+ });
89
+
90
+ it("does not show the hint after the local max has been reached", () => {
91
+ const host = createEditorHost();
92
+ globalThis.localStorage.setItem(
93
+ __testOnly.SLASH_DISCOVERY_STORAGE_KEY,
94
+ JSON.stringify({
95
+ shownCount: __testOnly.SLASH_HINT_MAX_SHOW_COUNT,
96
+ completed: false,
97
+ }),
98
+ );
99
+
100
+ scheduleSlashCommandHint(host);
101
+ vi.advanceTimersByTime(__testOnly.SLASH_HINT_DELAY_MS);
102
+
103
+ expect(host.classList.contains(__testOnly.SLASH_HINT_VISIBLE_CLASS)).toBe(
104
+ false,
105
+ );
106
+ });
107
+
108
+ it("does not show the hint when the DB flag is already set", () => {
109
+ const host = createEditorHost(true);
110
+
111
+ scheduleSlashCommandHint(host);
112
+ vi.advanceTimersByTime(__testOnly.SLASH_HINT_DELAY_MS);
113
+
114
+ expect(host.classList.contains(__testOnly.SLASH_HINT_VISIBLE_CLASS)).toBe(
115
+ false,
116
+ );
117
+ });
118
+
119
+ it("does not show the hint on mobile widths", () => {
120
+ vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: false }));
121
+ const host = createEditorHost();
122
+
123
+ scheduleSlashCommandHint(host);
124
+ vi.advanceTimersByTime(__testOnly.SLASH_HINT_DELAY_MS);
125
+
126
+ expect(host.classList.contains(__testOnly.SLASH_HINT_VISIBLE_CLASS)).toBe(
127
+ false,
128
+ );
129
+ });
130
+
131
+ it("marks the slash command as completed locally and syncs to the server", async () => {
132
+ const host = createEditorHost();
133
+
134
+ markSlashCommandDiscovered();
135
+ await Promise.resolve();
136
+
137
+ expect(__testOnly.readState()).toMatchObject({
138
+ shownCount: __testOnly.SLASH_HINT_MAX_SHOW_COUNT,
139
+ completed: true,
140
+ });
141
+ expect(host.dataset.slashCommandDiscovered).toBe("true");
142
+ expect(globalThis.fetch).toHaveBeenCalledWith(
143
+ __testOnly.SLASH_DISCOVERY_API_PATH,
144
+ expect.objectContaining({
145
+ method: "POST",
146
+ credentials: "same-origin",
147
+ }),
148
+ );
149
+ });
150
+ });
@@ -230,6 +230,7 @@ const labels: ComposeLabels = {
230
230
  showMore: "Show more",
231
231
  showLess: "Show less",
232
232
  newThread: "New Thread",
233
+ slashHint: "Type / for commands",
233
234
  collectionFormLabels: {
234
235
  titleLabel: "Title",
235
236
  titlePlaceholder: "My Collection",
@@ -264,6 +264,7 @@ const labels: ComposeLabels = {
264
264
  showMore: "Show more",
265
265
  showLess: "Show less",
266
266
  newThread: "New Thread",
267
+ slashHint: "Type / for commands",
267
268
  collectionFormLabels: {
268
269
  titleLabel: "Title",
269
270
  titlePlaceholder: "My Collection",
@@ -214,6 +214,7 @@ export interface ComposeLabels {
214
214
  showMore: string;
215
215
  showLess: string;
216
216
  newThread: string;
217
+ slashHint: string;
217
218
  collectionFormLabels: CollectionFormLabels;
218
219
  }
219
220
 
@@ -534,6 +534,10 @@ export class JantComposeDialog extends LitElement {
534
534
  pageMode: { type: Boolean, attribute: "page-mode" },
535
535
  closeHref: { type: String, attribute: "close-href" },
536
536
  autoRestoreDraft: { type: Boolean, attribute: "auto-restore-draft" },
537
+ slashCommandDiscovered: {
538
+ type: Boolean,
539
+ attribute: "slash-command-discovered",
540
+ },
537
541
  _format: { state: true },
538
542
  _status: { state: true },
539
543
  _loading: { state: true },
@@ -578,6 +582,7 @@ export class JantComposeDialog extends LitElement {
578
582
  declare pageMode: boolean;
579
583
  declare closeHref: string;
580
584
  declare autoRestoreDraft: boolean;
585
+ declare slashCommandDiscovered: boolean;
581
586
  declare _format: ComposeFormat;
582
587
  declare _status: "published" | "draft";
583
588
  declare _loading: boolean;
@@ -665,6 +670,7 @@ export class JantComposeDialog extends LitElement {
665
670
  this.pageMode = false;
666
671
  this.closeHref = "/";
667
672
  this.autoRestoreDraft = false;
673
+ this.slashCommandDiscovered = false;
668
674
  this._format = "note";
669
675
  this._status = "published";
670
676
  this._loading = false;
@@ -5283,6 +5289,7 @@ export class JantComposeDialog extends LitElement {
5283
5289
  .uploadMaxFileSize=${this.uploadMaxFileSize}
5284
5290
  .threadItem=${true}
5285
5291
  .removable=${showRemove}
5292
+ .slashCommandDiscovered=${this.slashCommandDiscovered}
5286
5293
  data-thread-id=${item.id}
5287
5294
  ></jant-compose-editor>
5288
5295
  </div>
@@ -5387,6 +5394,7 @@ export class JantComposeDialog extends LitElement {
5387
5394
  .format=${this._format}
5388
5395
  .labels=${this.labels}
5389
5396
  .uploadMaxFileSize=${this.uploadMaxFileSize}
5397
+ .slashCommandDiscovered=${this.slashCommandDiscovered}
5390
5398
  ></jant-compose-editor>`;
5391
5399
 
5392
5400
  return html`
@@ -48,6 +48,11 @@ import {
48
48
  } from "../tiptap/inline-image-upload.js";
49
49
  import { isSafeAbsoluteUrl } from "../../lib/url.js";
50
50
  import { randomUUID } from "../random-uuid.js";
51
+ import {
52
+ hideSlashCommandHint,
53
+ markSlashCommandDiscovered,
54
+ scheduleSlashCommandHint,
55
+ } from "../slash-discovery.js";
51
56
 
52
57
  interface ComposeFilePickerCloseDetail {
53
58
  cancelled: boolean;
@@ -172,6 +177,7 @@ export class JantComposeEditor extends LitElement {
172
177
  uploadMaxFileSize: { type: Number },
173
178
  threadItem: { type: Boolean, attribute: "thread-item" },
174
179
  removable: { type: Boolean },
180
+ slashCommandDiscovered: { type: Boolean },
175
181
  _title: { state: true },
176
182
  _bodyJson: { state: true },
177
183
  _url: { state: true },
@@ -196,6 +202,7 @@ export class JantComposeEditor extends LitElement {
196
202
  declare uploadMaxFileSize: number;
197
203
  declare threadItem: boolean;
198
204
  declare removable: boolean;
205
+ declare slashCommandDiscovered: boolean;
199
206
  declare _title: string;
200
207
  declare _bodyJson: JSONContent | null;
201
208
  declare _url: string;
@@ -242,6 +249,7 @@ export class JantComposeEditor extends LitElement {
242
249
  this.uploadMaxFileSize = 500;
243
250
  this.threadItem = false;
244
251
  this.removable = false;
252
+ this.slashCommandDiscovered = false;
245
253
  this._title = "";
246
254
  this._bodyJson = null;
247
255
  this._url = "";
@@ -264,6 +272,10 @@ export class JantComposeEditor extends LitElement {
264
272
  connectedCallback() {
265
273
  super.connectedCallback();
266
274
  document.addEventListener("jant:slash-image", this._onSlashImage);
275
+ document.addEventListener(
276
+ "jant:slash-command-discovered",
277
+ this._onSlashCommandDiscovered,
278
+ );
267
279
  }
268
280
 
269
281
  disconnectedCallback() {
@@ -274,13 +286,22 @@ export class JantComposeEditor extends LitElement {
274
286
  this.#sortable?.destroy();
275
287
  this.#sortable = null;
276
288
  document.removeEventListener("jant:slash-image", this._onSlashImage);
289
+ document.removeEventListener(
290
+ "jant:slash-command-discovered",
291
+ this._onSlashCommandDiscovered,
292
+ );
277
293
  document.removeEventListener("click", this._onDocClickBound);
294
+ hideSlashCommandHint(this);
278
295
  this._emojiContainer?.remove();
279
296
  this._emojiPickerEl = null;
280
297
  this._filePickerCleanup?.();
281
298
  this._filePickerCleanup = null;
282
299
  }
283
300
 
301
+ private _onSlashCommandDiscovered = () => {
302
+ markSlashCommandDiscovered();
303
+ };
304
+
284
305
  private _onSlashImage = () => {
285
306
  // Skip when fullscreen is open — it has its own handler
286
307
  if (document.querySelector(".compose-fullscreen-dialog[open]")) return;
@@ -686,6 +707,10 @@ export class JantComposeEditor extends LitElement {
686
707
  const container = this.querySelector<HTMLElement>(".compose-tiptap-body");
687
708
  if (!container || this._editor) return;
688
709
 
710
+ this.dataset.slashCommandDiscovered = this.slashCommandDiscovered
711
+ ? "true"
712
+ : "false";
713
+
689
714
  this._editor = createTiptapEditor({
690
715
  element: container,
691
716
  placeholder:
@@ -697,9 +722,13 @@ export class JantComposeEditor extends LitElement {
697
722
  onUpdate: (json) => {
698
723
  this._bodyJson = json;
699
724
  this._ensureScrollBuffer();
725
+ hideSlashCommandHint(this);
700
726
  },
701
727
  onFocus: () => {
702
728
  this._lastFocusedField = null;
729
+ if (this._editor?.isEmpty) {
730
+ scheduleSlashCommandHint(this);
731
+ }
703
732
  },
704
733
  onSelectionUpdate: (selection) => {
705
734
  this._lastEditorSelection = selection;
@@ -1655,7 +1684,12 @@ export class JantComposeEditor extends LitElement {
1655
1684
  </div>
1656
1685
  `
1657
1686
  : nothing}
1658
- <div class="compose-tiptap-body"></div>
1687
+ <div class="compose-tiptap-wrap">
1688
+ <div class="compose-tiptap-body"></div>
1689
+ <span class="compose-slash-discovery-hint" aria-hidden="true">
1690
+ ${this.labels.slashHint}
1691
+ </span>
1692
+ </div>
1659
1693
  </div>
1660
1694
  `;
1661
1695
  }
@@ -1723,9 +1757,14 @@ export class JantComposeEditor extends LitElement {
1723
1757
  </p>`
1724
1758
  : nothing}
1725
1759
  <div class="compose-divider"></div>
1726
- <div
1727
- class="compose-tiptap-body compose-tiptap-thoughts compose-tiptap-link"
1728
- ></div>
1760
+ <div class="compose-tiptap-wrap">
1761
+ <div
1762
+ class="compose-tiptap-body compose-tiptap-thoughts compose-tiptap-link"
1763
+ ></div>
1764
+ <span class="compose-slash-discovery-hint" aria-hidden="true">
1765
+ ${this.labels.slashHint}
1766
+ </span>
1767
+ </div>
1729
1768
  </div>
1730
1769
  `;
1731
1770
  }
@@ -1789,9 +1828,14 @@ export class JantComposeEditor extends LitElement {
1789
1828
  class="compose-divider compose-divider-quote"
1790
1829
  aria-hidden="true"
1791
1830
  ></div>
1792
- <div
1793
- class="compose-tiptap-body compose-tiptap-thoughts compose-tiptap-thoughts-quote"
1794
- ></div>
1831
+ <div class="compose-tiptap-wrap">
1832
+ <div
1833
+ class="compose-tiptap-body compose-tiptap-thoughts compose-tiptap-thoughts-quote"
1834
+ ></div>
1835
+ <span class="compose-slash-discovery-hint" aria-hidden="true">
1836
+ ${this.labels.slashHint}
1837
+ </span>
1838
+ </div>
1795
1839
  </div>
1796
1840
  `;
1797
1841
  }
@@ -0,0 +1,9 @@
1
+ import { initSlashCommandDiscovery } from "./slash-discovery.js";
2
+
3
+ if (document.readyState === "loading") {
4
+ document.addEventListener("DOMContentLoaded", () => {
5
+ initSlashCommandDiscovery();
6
+ });
7
+ } else {
8
+ initSlashCommandDiscovery();
9
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Slash Command Discovery
3
+ *
4
+ * Surfaces a small "Type / for commands" hint over the TipTap compose editor on
5
+ * first focus. Mirrors the compose-open-shortcut pattern:
6
+ * - localStorage tracks shownCount + completed for fast read on subsequent visits
7
+ * - DB (synced once via POST) is the durable cross-device "user has used this" flag
8
+ * - The DB flag is hydrated server-side onto the editor host as
9
+ * `data-slash-command-discovered="true"`
10
+ */
11
+
12
+ const SLASH_DISCOVERY_STORAGE_KEY = "jant.slashCommandDiscovery";
13
+ const SLASH_DISCOVERY_API_PATH = "/api/settings/discovery/slash-command";
14
+ const SLASH_HINT_DELAY_MS = 600;
15
+ const SLASH_HINT_MAX_SHOW_COUNT = 3;
16
+ const SLASH_HINT_VISIBLE_CLASS = "compose-slash-discovery-visible";
17
+
18
+ interface SlashDiscoveryState {
19
+ shownCount: number;
20
+ completed: boolean;
21
+ }
22
+
23
+ let exposureRecordedThisSession = false;
24
+ let showTimer: ReturnType<typeof setTimeout> | null = null;
25
+ let activeHost: HTMLElement | null = null;
26
+
27
+ function canUseLocalStorage(): boolean {
28
+ try {
29
+ return typeof globalThis.localStorage !== "undefined";
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ function readState(): SlashDiscoveryState {
36
+ if (!canUseLocalStorage()) {
37
+ return { shownCount: 0, completed: false };
38
+ }
39
+
40
+ const raw = globalThis.localStorage.getItem(SLASH_DISCOVERY_STORAGE_KEY);
41
+ if (!raw) {
42
+ return { shownCount: 0, completed: false };
43
+ }
44
+
45
+ try {
46
+ const parsed = JSON.parse(raw) as Partial<SlashDiscoveryState>;
47
+ return {
48
+ shownCount:
49
+ typeof parsed.shownCount === "number" && parsed.shownCount >= 0
50
+ ? parsed.shownCount
51
+ : 0,
52
+ completed: parsed.completed === true,
53
+ };
54
+ } catch {
55
+ globalThis.localStorage.removeItem(SLASH_DISCOVERY_STORAGE_KEY);
56
+ return { shownCount: 0, completed: false };
57
+ }
58
+ }
59
+
60
+ function writeState(state: SlashDiscoveryState): void {
61
+ if (!canUseLocalStorage()) return;
62
+
63
+ try {
64
+ globalThis.localStorage.setItem(
65
+ SLASH_DISCOVERY_STORAGE_KEY,
66
+ JSON.stringify(state),
67
+ );
68
+ } catch {
69
+ // Storage unavailable — skip persistence for this page load.
70
+ }
71
+ }
72
+
73
+ function clearShowTimer() {
74
+ if (showTimer !== null) {
75
+ clearTimeout(showTimer);
76
+ showTimer = null;
77
+ }
78
+ }
79
+
80
+ function isDesktopEligible(): boolean {
81
+ return (
82
+ typeof globalThis.matchMedia === "function" &&
83
+ globalThis.matchMedia("(min-width: 700px)").matches
84
+ );
85
+ }
86
+
87
+ function isCompletedOnHost(host: HTMLElement): boolean {
88
+ if (readState().completed) return true;
89
+ return host.dataset.slashCommandDiscovered === "true";
90
+ }
91
+
92
+ function markHostsCompleted() {
93
+ document
94
+ .querySelectorAll<HTMLElement>("jant-compose-editor")
95
+ .forEach((host) => {
96
+ host.dataset.slashCommandDiscovered = "true";
97
+ host.classList.remove(SLASH_HINT_VISIBLE_CLASS);
98
+ });
99
+ }
100
+
101
+ function recordExposure(): void {
102
+ const state = readState();
103
+ if (state.completed || exposureRecordedThisSession) return;
104
+ if (state.shownCount >= SLASH_HINT_MAX_SHOW_COUNT) return;
105
+
106
+ exposureRecordedThisSession = true;
107
+ writeState({ ...state, shownCount: state.shownCount + 1 });
108
+ }
109
+
110
+ function canShowHint(host: HTMLElement): boolean {
111
+ if (!isDesktopEligible()) return false;
112
+ if (isCompletedOnHost(host)) return false;
113
+
114
+ const state = readState();
115
+ return (
116
+ state.shownCount < SLASH_HINT_MAX_SHOW_COUNT || exposureRecordedThisSession
117
+ );
118
+ }
119
+
120
+ export function hideSlashCommandHint(host?: HTMLElement | null): void {
121
+ clearShowTimer();
122
+
123
+ if (host) {
124
+ host.classList.remove(SLASH_HINT_VISIBLE_CLASS);
125
+ } else if (activeHost) {
126
+ activeHost.classList.remove(SLASH_HINT_VISIBLE_CLASS);
127
+ }
128
+
129
+ if (!host || host === activeHost) {
130
+ activeHost = null;
131
+ }
132
+ }
133
+
134
+ function showHint(host: HTMLElement): void {
135
+ if (!canShowHint(host)) return;
136
+
137
+ hideSlashCommandHint(activeHost);
138
+ activeHost = host;
139
+ host.classList.add(SLASH_HINT_VISIBLE_CLASS);
140
+ recordExposure();
141
+ }
142
+
143
+ export function scheduleSlashCommandHint(host: HTMLElement): void {
144
+ if (!canShowHint(host)) return;
145
+ if (host.classList.contains(SLASH_HINT_VISIBLE_CLASS)) return;
146
+
147
+ clearShowTimer();
148
+ showTimer = setTimeout(() => {
149
+ showHint(host);
150
+ }, SLASH_HINT_DELAY_MS);
151
+ }
152
+
153
+ export function markSlashCommandDiscovered(): void {
154
+ const state = readState();
155
+ if (!state.completed) {
156
+ writeState({
157
+ shownCount: Math.max(state.shownCount, SLASH_HINT_MAX_SHOW_COUNT),
158
+ completed: true,
159
+ });
160
+ }
161
+
162
+ markHostsCompleted();
163
+ hideSlashCommandHint(activeHost);
164
+
165
+ if (typeof globalThis.fetch === "function") {
166
+ void globalThis
167
+ .fetch(SLASH_DISCOVERY_API_PATH, {
168
+ method: "POST",
169
+ headers: { Accept: "application/json" },
170
+ credentials: "same-origin",
171
+ })
172
+ .catch(() => {
173
+ // Keep the local completed state even if the sync request fails.
174
+ });
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Initialize once on DOMContentLoaded. The actual scheduling is driven by
180
+ * the compose editor on focus, so this is a no-op placeholder kept for parity
181
+ * with `compose-discovery-bridge` (and to give future call sites a hook).
182
+ */
183
+ export function initSlashCommandDiscovery(): void {
184
+ // Intentionally empty — the editor host calls scheduleSlashCommandHint /
185
+ // hideSlashCommandHint directly on focus / update.
186
+ }
187
+
188
+ export const __testOnly = {
189
+ SLASH_DISCOVERY_API_PATH,
190
+ SLASH_DISCOVERY_STORAGE_KEY,
191
+ SLASH_HINT_DELAY_MS,
192
+ SLASH_HINT_MAX_SHOW_COUNT,
193
+ SLASH_HINT_VISIBLE_CLASS,
194
+ readState,
195
+ reset() {
196
+ exposureRecordedThisSession = false;
197
+ clearShowTimer();
198
+ activeHost = null;
199
+ },
200
+ };
@@ -641,6 +641,11 @@ export const SlashCommands = Extension.create({
641
641
  editorRef = props.editor;
642
642
  currentRange = props.range;
643
643
  renderPopup(props.items, (index) => props.command({ index }));
644
+ document.dispatchEvent(
645
+ new CustomEvent("jant:slash-command-discovered", {
646
+ bubbles: true,
647
+ }),
648
+ );
644
649
 
645
650
  // Append inside the closest dialog (top-layer) or body
646
651
  const editorEl = getEditorElement(props.editor);
@@ -14,6 +14,7 @@ import "./client/components/jant-compose-editor.js";
14
14
  import "./client/components/jant-compose-fullscreen.js";
15
15
  import "./client/compose-bridge.js";
16
16
  import "./client/compose-discovery-bridge.js";
17
+ import "./client/slash-discovery-bridge.js";
17
18
  import "./client/compose-shortcuts.js";
18
19
  import "./client/components/jant-settings-general.js";
19
20
  import "./client/components/jant-settings-avatar.js";
@@ -2131,6 +2131,11 @@ msgstr "Transparent square"
2131
2131
  msgid "Tune color in a real reading context"
2132
2132
  msgstr "Tune color in a real reading context"
2133
2133
 
2134
+ #. @context: First-use hint shown over the compose editor to surface the slash command menu
2135
+ #: src/ui/compose/ComposeDialog.tsx
2136
+ msgid "Type / for commands"
2137
+ msgstr "Type / for commands"
2138
+
2134
2139
  #. @context: Compose quote text placeholder
2135
2140
  #: src/ui/compose/ComposeDialog.tsx
2136
2141
  msgid "Type the quote..."