@devaccel-labs/app-comments-toolbar 1.0.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/README.md +152 -18
  2. package/dist/react.js +516 -61
  3. package/dist/server.js +342 -14
  4. package/dist/types/capture/api.d.ts +30 -0
  5. package/dist/types/capture/editor.d.ts +10 -0
  6. package/dist/types/capture/ignore.d.ts +19 -0
  7. package/dist/types/capture/index.d.ts +1 -0
  8. package/dist/types/capture/picker.d.ts +9 -0
  9. package/dist/types/capture/picture.d.ts +29 -0
  10. package/dist/types/capture/surface.d.ts +39 -0
  11. package/dist/types/core/index.d.ts +2 -2
  12. package/dist/types/core/resolve.d.ts +2 -1
  13. package/dist/types/core/types.d.ts +11 -0
  14. package/dist/types/debug-context.d.ts +7 -0
  15. package/dist/types/dictation.d.ts +9 -0
  16. package/dist/types/embed/controller.d.ts +61 -0
  17. package/dist/types/embed/index.d.ts +1 -0
  18. package/dist/types/embed/options.d.ts +35 -0
  19. package/dist/types/help/index.d.ts +1 -0
  20. package/dist/types/help/panel-index.d.ts +1 -0
  21. package/dist/types/help/panel-styles.d.ts +1 -0
  22. package/dist/types/help/panel.d.ts +4 -0
  23. package/dist/types/help/pill-styles.d.ts +11 -0
  24. package/dist/types/help/pill.d.ts +44 -0
  25. package/dist/types/help/react.d.ts +12 -0
  26. package/dist/types/help/requests.d.ts +2 -0
  27. package/dist/types/help/types.d.ts +119 -0
  28. package/dist/types/help/wire.d.ts +61 -0
  29. package/dist/types/native-wire.d.ts +63 -0
  30. package/dist/types/native.d.ts +43 -0
  31. package/dist/types/react.d.ts +8 -1
  32. package/dist/types/replay.d.ts +6 -0
  33. package/dist/types/runtime/api.d.ts +250 -0
  34. package/dist/types/runtime/attachment-model.d.ts +16 -0
  35. package/dist/types/runtime/attachments.d.ts +43 -7
  36. package/dist/types/runtime/boot.d.ts +39 -0
  37. package/dist/types/runtime/browser-label.d.ts +5 -0
  38. package/dist/types/runtime/build-id.d.ts +7 -0
  39. package/dist/types/runtime/capture-ui.d.ts +45 -21
  40. package/dist/types/runtime/clarity-model.d.ts +260 -0
  41. package/dist/types/runtime/clarity-ui.d.ts +62 -0
  42. package/dist/types/runtime/client.d.ts +6 -197
  43. package/dist/types/runtime/composer-size.d.ts +30 -0
  44. package/dist/types/runtime/dashboard.d.ts +17 -2
  45. package/dist/types/runtime/debug-context-core.d.ts +88 -0
  46. package/dist/types/runtime/debug-context-host.d.ts +39 -0
  47. package/dist/types/runtime/debug-context-model.d.ts +352 -0
  48. package/dist/types/runtime/debug-context-redact.d.ts +48 -0
  49. package/dist/types/runtime/debug-context.d.ts +36 -0
  50. package/dist/types/runtime/details-model.d.ts +7 -0
  51. package/dist/types/runtime/details.d.ts +7 -5
  52. package/dist/types/runtime/dictation-engine.d.ts +98 -0
  53. package/dist/types/runtime/dictation-model.d.ts +28 -0
  54. package/dist/types/runtime/dictation-ui.d.ts +56 -0
  55. package/dist/types/runtime/dictation.d.ts +66 -0
  56. package/dist/types/runtime/draft-dock.d.ts +32 -0
  57. package/dist/types/runtime/draft-model.d.ts +104 -0
  58. package/dist/types/runtime/draft-store.d.ts +15 -0
  59. package/dist/types/runtime/edits.d.ts +1 -1
  60. package/dist/types/runtime/font.d.ts +16 -0
  61. package/dist/types/runtime/format.d.ts +30 -1
  62. package/dist/types/runtime/host-watch.d.ts +20 -0
  63. package/dist/types/runtime/icons.d.ts +16 -2
  64. package/dist/types/runtime/index.d.ts +3 -1
  65. package/dist/types/runtime/ink.d.ts +8 -0
  66. package/dist/types/runtime/kind-model.d.ts +14 -0
  67. package/dist/types/runtime/live-model.d.ts +25 -0
  68. package/dist/types/runtime/loader.d.ts +5 -11
  69. package/dist/types/runtime/markup-editor.d.ts +54 -0
  70. package/dist/types/runtime/markup-model.d.ts +126 -0
  71. package/dist/types/runtime/markup.d.ts +69 -0
  72. package/dist/types/runtime/mention-parse.d.ts +18 -0
  73. package/dist/types/runtime/mentions.d.ts +4 -4
  74. package/dist/types/runtime/page-background.d.ts +9 -0
  75. package/dist/types/runtime/page-image.d.ts +123 -0
  76. package/dist/types/runtime/placement.d.ts +6 -3
  77. package/dist/types/runtime/record.d.ts +18 -2
  78. package/dist/types/runtime/related.d.ts +1 -1
  79. package/dist/types/runtime/replay-model.d.ts +80 -0
  80. package/dist/types/runtime/replay.d.ts +5 -38
  81. package/dist/types/runtime/runtime.d.ts +6 -0
  82. package/dist/types/runtime/screen.d.ts +47 -0
  83. package/dist/types/runtime/screenshot.d.ts +60 -6
  84. package/dist/types/runtime/session-source.d.ts +45 -0
  85. package/dist/types/runtime/session.d.ts +20 -18
  86. package/dist/types/runtime/shield.d.ts +9 -0
  87. package/dist/types/runtime/shot.d.ts +45 -0
  88. package/dist/types/runtime/sketch.d.ts +26 -0
  89. package/dist/types/runtime/styles.d.ts +1 -1
  90. package/dist/types/runtime/sync.d.ts +1 -0
  91. package/dist/types/runtime/thread-model.d.ts +18 -0
  92. package/dist/types/runtime/ticket-form-model.d.ts +57 -0
  93. package/dist/types/runtime/ticket-form.d.ts +9 -0
  94. package/dist/types/runtime/toolbar.d.ts +37 -1
  95. package/dist/types/runtime/ui-scale.d.ts +17 -0
  96. package/dist/types/runtime/update.d.ts +37 -0
  97. package/dist/types/runtime/use-debug-context.d.ts +27 -0
  98. package/dist/types/server/index.d.ts +22 -0
  99. package/dist/types/server/support.d.ts +20 -0
  100. package/dist/types/server.d.ts +2 -0
  101. package/dist/types/vite/plugin.d.ts +15 -0
  102. package/dist/vite.js +121 -41
  103. package/package.json +26 -13
  104. package/src/capture/api.ts +128 -0
  105. package/src/capture/editor.tsx +178 -0
  106. package/src/capture/ignore.ts +38 -0
  107. package/src/capture/index.ts +27 -0
  108. package/src/capture/picker.tsx +171 -0
  109. package/src/capture/picture.ts +90 -0
  110. package/src/capture/surface.tsx +185 -0
  111. package/src/compiler/annotate.ts +202 -0
  112. package/src/compiler/babel.d.ts +11 -0
  113. package/src/compiler/enabled.ts +55 -0
  114. package/src/compiler/index.ts +2 -0
  115. package/src/core/index.ts +8 -0
  116. package/src/core/resolve.ts +307 -0
  117. package/src/core/types.ts +58 -0
  118. package/src/debug-context.ts +47 -0
  119. package/src/dictation.ts +17 -0
  120. package/src/embed/controller.ts +215 -0
  121. package/src/embed/index.ts +68 -0
  122. package/src/embed/options.ts +100 -0
  123. package/src/help/index.ts +38 -0
  124. package/src/help/panel-index.ts +17 -0
  125. package/src/help/panel-styles.ts +123 -0
  126. package/src/help/panel.ts +840 -0
  127. package/src/help/pill-styles.ts +75 -0
  128. package/src/help/pill.ts +338 -0
  129. package/src/help/react.tsx +143 -0
  130. package/src/help/requests.ts +111 -0
  131. package/src/help/types.ts +124 -0
  132. package/src/help/wire.ts +55 -0
  133. package/src/native-wire.ts +124 -0
  134. package/src/native.ts +282 -0
  135. package/src/react.ts +27 -0
  136. package/src/replay.ts +13 -0
  137. package/src/runtime/api.ts +531 -0
  138. package/src/runtime/attachment-model.ts +42 -0
  139. package/src/runtime/attachments.tsx +1260 -0
  140. package/src/runtime/boot.ts +95 -0
  141. package/src/runtime/browser-label.ts +31 -0
  142. package/src/runtime/build-id.ts +10 -0
  143. package/src/runtime/capture-ui.tsx +306 -0
  144. package/src/runtime/capture.ts +508 -0
  145. package/src/runtime/clarity-model.ts +634 -0
  146. package/src/runtime/clarity-ui.tsx +454 -0
  147. package/src/runtime/client.ts +151 -0
  148. package/src/runtime/composer-size.tsx +146 -0
  149. package/src/runtime/dashboard.tsx +1130 -0
  150. package/src/runtime/debug-context-core.ts +681 -0
  151. package/src/runtime/debug-context-host.ts +56 -0
  152. package/src/runtime/debug-context-model.ts +1319 -0
  153. package/src/runtime/debug-context-redact.ts +259 -0
  154. package/src/runtime/debug-context.ts +613 -0
  155. package/src/runtime/details-model.ts +30 -0
  156. package/src/runtime/details.tsx +395 -0
  157. package/src/runtime/dictation-engine.ts +616 -0
  158. package/src/runtime/dictation-model.ts +79 -0
  159. package/src/runtime/dictation-ui.tsx +363 -0
  160. package/src/runtime/dictation.ts +271 -0
  161. package/src/runtime/dock.ts +108 -0
  162. package/src/runtime/draft-dock.tsx +124 -0
  163. package/src/runtime/draft-model.ts +251 -0
  164. package/src/runtime/draft-store.ts +149 -0
  165. package/src/runtime/edits.tsx +165 -0
  166. package/src/runtime/font.ts +42 -0
  167. package/src/runtime/format.ts +129 -0
  168. package/src/runtime/freshness.ts +23 -0
  169. package/src/runtime/handoff.ts +76 -0
  170. package/src/runtime/host-watch.ts +64 -0
  171. package/src/runtime/icons.tsx +295 -0
  172. package/src/runtime/idle.ts +20 -0
  173. package/src/runtime/index.ts +11 -0
  174. package/src/runtime/ink.ts +6 -0
  175. package/src/runtime/kind-model.ts +20 -0
  176. package/src/runtime/live-model.ts +32 -0
  177. package/src/runtime/loader.tsx +87 -0
  178. package/src/runtime/markup-editor.tsx +1333 -0
  179. package/src/runtime/markup-model.ts +485 -0
  180. package/src/runtime/markup.ts +382 -0
  181. package/src/runtime/members.ts +58 -0
  182. package/src/runtime/mention-parse.ts +56 -0
  183. package/src/runtime/mentions.tsx +205 -0
  184. package/src/runtime/page-background.ts +51 -0
  185. package/src/runtime/page-image.ts +1592 -0
  186. package/src/runtime/placement.ts +336 -0
  187. package/src/runtime/record.ts +402 -0
  188. package/src/runtime/redact.ts +30 -0
  189. package/src/runtime/related.ts +34 -0
  190. package/src/runtime/render-page.ts +2 -0
  191. package/src/runtime/replay-model.ts +232 -0
  192. package/src/runtime/replay.ts +173 -0
  193. package/src/runtime/runtime-version.ts +7 -0
  194. package/src/runtime/runtime.tsx +51 -0
  195. package/src/runtime/screen.ts +182 -0
  196. package/src/runtime/screenshot.ts +260 -0
  197. package/src/runtime/session-source.ts +112 -0
  198. package/src/runtime/session.ts +108 -0
  199. package/src/runtime/shield.ts +34 -0
  200. package/src/runtime/shot.tsx +422 -0
  201. package/src/runtime/sketch.ts +139 -0
  202. package/src/runtime/styles.ts +1541 -0
  203. package/src/runtime/sync.ts +300 -0
  204. package/src/runtime/thread-model.ts +71 -0
  205. package/src/runtime/ticket-form-model.ts +128 -0
  206. package/src/runtime/ticket-form.tsx +342 -0
  207. package/src/runtime/toolbar.tsx +4427 -0
  208. package/src/runtime/ui-scale.ts +75 -0
  209. package/src/runtime/update.ts +98 -0
  210. package/src/runtime/use-debug-context.ts +81 -0
  211. package/src/server/index.ts +133 -0
  212. package/src/server/support.ts +499 -0
  213. package/src/server.ts +4 -0
  214. package/src/vite/plugin.ts +102 -0
  215. package/src/vite.ts +1 -0
  216. package/dist/chunks/chunk-CSKXLF33.js +0 -153
  217. package/dist/chunks/chunk-HUM5FQII.js +0 -160
  218. package/dist/chunks/render-page-76MLIT6M.js +0 -1421
  219. package/dist/chunks/runtime-JX75TKER.js +0 -9267
  220. package/dist/chunks/screenshot-66ARTBGR.js +0 -12
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The pill's CSS, and the ground the panel's CSS stands on (`./panel-styles.ts`): the reset and the colours.
3
+ *
4
+ * The pill lives in the page's own document, not in a shadow tree: a toolbar on the same page reads keys
5
+ * from `window` and tells "the person is typing" by the event's target, which a shadow tree would hide from
6
+ * it. So every element here is first cleared of whatever the host's stylesheet gives its kind (`all: unset`)
7
+ * and then styled by a rule under `.cth`, which outranks a host rule written for a bare element.
8
+ */
9
+
10
+ /** Above the page, below the comment toolbar (2147483000) and its capture UI. */
11
+ export const HELP_Z_INDEX = 2147482000;
12
+
13
+ const DARK = `
14
+ --cth-card: #1c1c20; --cth-ink: #f5f5f7; --cth-muted: #a8a8ad; --cth-line: rgba(255, 255, 255, 0.14);
15
+ --cth-ring: rgba(255, 255, 255, 0.1); --cth-fill: rgba(255, 255, 255, 0.08); --cth-fill-2: rgba(255, 255, 255, 0.13);
16
+ --cth-track: rgba(0, 0, 0, 0.34); --cth-raised: #3a3a41; --cth-field: #131316; --cth-link: #8ab4ff;
17
+ --cth-danger: #ff8a80; --cth-ok: #1f7a3d; --cth-received-bg: rgba(255, 255, 255, 0.1); --cth-received-ink: #d7d7dc;
18
+ --cth-progress-bg: rgba(255, 190, 60, 0.18); --cth-progress-ink: #ffd27a; --cth-resolved-bg: rgba(70, 200, 110, 0.18);
19
+ --cth-resolved-ink: #8fe0a8; --cth-shadow: 0 2px 6px rgba(0, 0, 0, 0.4), 0 16px 48px rgba(0, 0, 0, 0.5);
20
+ --cth-pill-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.45);
21
+ `;
22
+
23
+ export const HELP_BASE_CSS = `
24
+ .cth {
25
+ all: initial; display: contents;
26
+ --cth-card: #ffffff; --cth-ink: #141416; --cth-muted: #62625e; --cth-line: #e2e2dd; --cth-ring: rgba(20, 20, 30, 0.08);
27
+ --cth-fill: #f3f3f0; --cth-fill-2: #e9e9e4; --cth-track: #e9e9e4; --cth-raised: #ffffff; --cth-field: #ffffff;
28
+ --cth-accent: #2563eb; --cth-accent-ink: #ffffff; --cth-link: #1d4ed8; --cth-danger: #b42318; --cth-ok: #1f7a3d;
29
+ --cth-received-bg: #eeeeea; --cth-received-ink: #4a4a47; --cth-progress-bg: #fff1cc; --cth-progress-ink: #7a4f00;
30
+ --cth-resolved-bg: #e0f4e6; --cth-resolved-ink: #17612f;
31
+ --cth-shadow: 0 2px 6px rgba(20, 20, 30, 0.08), 0 16px 48px rgba(20, 20, 30, 0.18);
32
+ --cth-pill-shadow: 0 1px 2px rgba(20, 20, 30, 0.08), 0 8px 24px rgba(20, 20, 30, 0.14);
33
+ --cth-edge: 20px;
34
+ color: var(--cth-ink);
35
+ font: 400 13px/1.4 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Inter, system-ui, sans-serif;
36
+ letter-spacing: -0.005em; -webkit-font-smoothing: antialiased; text-align: left; direction: ltr;
37
+ }
38
+ .cth[data-theme="dark"] {${DARK}}
39
+ @media (prefers-color-scheme: dark) { .cth[data-theme="auto"] {${DARK}} }
40
+ .cth :is(a, button, div, form, h2, header, img, label, li, p, section, span, svg, textarea, time, ul) {
41
+ all: unset; box-sizing: border-box;
42
+ }
43
+ .cth :focus-visible { outline: 2px solid var(--cth-accent); outline-offset: 2px; }
44
+ .cth .cth-pill {
45
+ position: fixed; z-index: ${HELP_Z_INDEX};
46
+ bottom: calc(var(--cth-edge) + env(safe-area-inset-bottom, 0px) + var(--cth-lift, 0px));
47
+ display: inline-flex; align-items: center; gap: 8px; height: 40px; max-width: calc(100vw - 2 * var(--cth-edge));
48
+ padding: 0 16px 0 9px; border-radius: 999px; background: var(--cth-card); color: var(--cth-ink);
49
+ box-shadow: 0 0 0 1px var(--cth-ring), var(--cth-pill-shadow);
50
+ font-size: 13px; font-weight: 600; white-space: nowrap; cursor: pointer;
51
+ -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent;
52
+ }
53
+ .cth[data-position="bottom-right"] .cth-pill { right: calc(var(--cth-edge) + env(safe-area-inset-right, 0px)); }
54
+ .cth[data-position="bottom-left"] .cth-pill { left: calc(var(--cth-edge) + env(safe-area-inset-left, 0px)); }
55
+ .cth .cth-pill:hover { background: var(--cth-raised); box-shadow: 0 0 0 1px var(--cth-ring), var(--cth-shadow); }
56
+ .cth .cth-mark {
57
+ display: inline-flex; flex: none; align-items: center; justify-content: center; width: 22px; height: 22px;
58
+ border-radius: 50%; background: var(--cth-accent); color: var(--cth-accent-ink); font-size: 13px; font-weight: 700;
59
+ }
60
+ .cth .cth-label { display: block; overflow: hidden; text-overflow: ellipsis; }
61
+ .cth .cth-note {
62
+ position: fixed; z-index: ${HELP_Z_INDEX};
63
+ bottom: calc(var(--cth-edge) + 50px + env(safe-area-inset-bottom, 0px) + var(--cth-lift, 0px));
64
+ display: block; max-width: 240px; padding: 8px 12px; border-radius: 10px; background: var(--cth-ink);
65
+ color: var(--cth-card); font-size: 12px;
66
+ }
67
+ .cth[data-position="bottom-right"] .cth-note { right: var(--cth-edge); }
68
+ .cth[data-position="bottom-left"] .cth-note { left: var(--cth-edge); }
69
+ @media (prefers-reduced-motion: no-preference) {
70
+ .cth .cth-pill { transition: box-shadow 0.2s ease, transform 0.2s ease, bottom 0.2s ease; }
71
+ .cth .cth-pill:active { transform: scale(0.97); }
72
+ .cth .cth-pill[aria-busy="true"] .cth-mark { animation: cth-wait 0.9s ease-in-out infinite alternate; }
73
+ @keyframes cth-wait { to { opacity: 0.45; } }
74
+ }
75
+ `;
@@ -0,0 +1,338 @@
1
+ /**
2
+ * The pill: all that `help.js` puts on a page before anyone asks for help. A labelled button fixed to a
3
+ * bottom corner, a few kilobytes with its CSS, no React.
4
+ *
5
+ * The panel it opens (`./panel.ts`, served as `help-panel.js`) and the capture script (`../capture`, served
6
+ * as `capture.js`) are fetched the first time the pointer rests on the pill, it takes focus, or it is opened.
7
+ *
8
+ * There is one pill per page. `mount` called again changes its options and keeps the pill, an open panel and
9
+ * a draft; every handle `mount` has returned speaks for that one pill.
10
+ */
11
+ import type { CommentToolCaptureApi } from "../capture/api.js";
12
+ import { HELP_BASE_CSS } from "./pill-styles.js";
13
+ import { supportRequestsAt } from "./requests.js";
14
+ import {
15
+ HelpError,
16
+ type CommentToolHelpApi,
17
+ type HelpHandle,
18
+ type HelpOptions,
19
+ type HelpPosition,
20
+ type HelpTheme,
21
+ } from "./types.js";
22
+
23
+ /** What the panel is given: where to draw, what the host said, and the capture tools. */
24
+ export type PanelHost = {
25
+ /** The pill's root. The panel is appended to it, after the pill. */
26
+ root: HTMLElement;
27
+ pill: HTMLButtonElement;
28
+ /** The host's options as they are now. */
29
+ options(): HelpOptions;
30
+ /** The capture script's API, fetched when it is not on the page yet. Rejects when it cannot be. */
31
+ capture(): Promise<CommentToolCaptureApi>;
32
+ /** Hides the pill and the panel while a capture is open, and brings them back. */
33
+ hide(hidden: boolean): void;
34
+ };
35
+
36
+ /** The panel, as the pill drives it. Additive only: a page may hold a `help.js` older than its panel. */
37
+ export type PanelView = {
38
+ readonly isOpen: boolean;
39
+ open(): void;
40
+ close(): void;
41
+ /** The host's options changed. */
42
+ update(): void;
43
+ destroy(): void;
44
+ };
45
+
46
+ export type HelpPanelApi = { create(host: PanelHost): PanelView };
47
+
48
+ export type HelpEnvironment = {
49
+ /** Where this script's siblings are served: `<service>/v1/toolbar/`. */
50
+ base: string;
51
+ /** Adds a classic script to the page. Default: a `<script async>` in `<head>`. */
52
+ loadScript?: (url: string) => Promise<void>;
53
+ };
54
+
55
+ const STYLE_ID = "cth-style";
56
+ const DEFAULT_LABEL = "Need help?";
57
+ const POSITIONS: readonly HelpPosition[] = ["bottom-right", "bottom-left"];
58
+ const THEMES: readonly HelpTheme[] = ["light", "dark", "auto"];
59
+ /** The gap kept between the pill and a comment toolbar's bar. */
60
+ const CLEARANCE = 8;
61
+ /** A bar this close to the bottom of the window is in a sheet's way. */
62
+ const SHEET_ZONE = 120;
63
+
64
+ function addScript(url: string): Promise<void> {
65
+ return new Promise((resolve, reject) => {
66
+ const tag = document.createElement("script");
67
+ tag.src = url;
68
+ tag.async = true;
69
+ tag.onload = () => resolve();
70
+ tag.onerror = () => {
71
+ tag.remove();
72
+ reject(new Error(`${url} did not load.`));
73
+ };
74
+ document.head.appendChild(tag);
75
+ });
76
+ }
77
+
78
+ /** Runs `load` once; a failure is forgotten, so the next call tries again. */
79
+ function once<T>(load: () => Promise<T>): () => Promise<T> {
80
+ let kept: Promise<T> | null = null;
81
+ return () => {
82
+ kept ??= load().catch((error: unknown) => {
83
+ kept = null;
84
+ throw error;
85
+ });
86
+ return kept;
87
+ };
88
+ }
89
+
90
+ export function ensureHelpStyles(id: string, css: string): void {
91
+ if (document.getElementById(id)) return;
92
+ const style = document.createElement("style");
93
+ style.id = id;
94
+ style.textContent = css;
95
+ document.head.appendChild(style);
96
+ }
97
+
98
+ type Page = {
99
+ CommentToolHelpPanel?: Partial<HelpPanelApi>;
100
+ CommentToolCapture?: Partial<CommentToolCaptureApi>;
101
+ };
102
+
103
+ export function createHelp(environment: HelpEnvironment): CommentToolHelpApi {
104
+ const load = environment.loadScript ?? addScript;
105
+ const page = window as unknown as Page;
106
+
107
+ const panelApi = once(async (): Promise<HelpPanelApi> => {
108
+ if (typeof page.CommentToolHelpPanel?.create !== "function") {
109
+ await load(`${environment.base}help-panel.js`);
110
+ }
111
+ const api = page.CommentToolHelpPanel;
112
+ if (typeof api?.create !== "function") throw new Error("help-panel.js defined no panel.");
113
+ return api as HelpPanelApi;
114
+ });
115
+ const captureApi = once(async (): Promise<CommentToolCaptureApi> => {
116
+ if (typeof page.CommentToolCapture?.screenshot !== "function") {
117
+ await load(`${environment.base}capture.js`);
118
+ }
119
+ const api = page.CommentToolCapture;
120
+ if (typeof api?.screenshot !== "function") throw new Error("capture.js defined no capture.");
121
+ return api as CommentToolCaptureApi;
122
+ });
123
+
124
+ let options: HelpOptions | null = null;
125
+ let root: HTMLDivElement | null = null;
126
+ let pill: HTMLButtonElement | null = null;
127
+ let label: HTMLSpanElement | null = null;
128
+ let note: HTMLDivElement | null = null;
129
+ let view: PanelView | null = null;
130
+ let wantOpen = false;
131
+ let lift = 0;
132
+ let stopWatching: (() => void) | null = null;
133
+
134
+ const capturesOn = () => {
135
+ const captures = options?.captures;
136
+ try {
137
+ return typeof captures === "function" ? captures() !== false : captures !== false;
138
+ } catch {
139
+ return false;
140
+ }
141
+ };
142
+
143
+ /** Fetches what the first open needs, ahead of it. */
144
+ function warm() {
145
+ void panelApi().catch(() => {});
146
+ if (capturesOn()) void captureApi().catch(() => {});
147
+ }
148
+
149
+ function say(message: string | null) {
150
+ note?.remove();
151
+ note = null;
152
+ if (!message || !root) return;
153
+ note = document.createElement("div");
154
+ note.className = "cth-note";
155
+ note.setAttribute("role", "status");
156
+ note.textContent = message;
157
+ root.appendChild(note);
158
+ }
159
+
160
+ async function panel(): Promise<PanelView | null> {
161
+ if (view) return view;
162
+ const mountedRoot = root;
163
+ const api = await panelApi();
164
+ // Unmounted, or mounted afresh, while the panel was on its way.
165
+ if (!root || !pill || root !== mountedRoot) return null;
166
+ view ??= api.create({
167
+ root,
168
+ pill,
169
+ options: () => options as HelpOptions,
170
+ capture: captureApi,
171
+ hide: (hidden) => {
172
+ if (root) root.style.display = hidden ? "none" : "";
173
+ },
174
+ });
175
+ return view;
176
+ }
177
+
178
+ function open() {
179
+ if (!pill) return;
180
+ wantOpen = true;
181
+ say(null);
182
+ if (view) {
183
+ view.open();
184
+ return;
185
+ }
186
+ pill.setAttribute("aria-busy", "true");
187
+ warm();
188
+ panel()
189
+ .then((ready) => {
190
+ if (ready && wantOpen) ready.open();
191
+ })
192
+ .catch(() => say("Help could not open. Try again."))
193
+ .finally(() => pill?.removeAttribute("aria-busy"));
194
+ }
195
+
196
+ function close() {
197
+ wantOpen = false;
198
+ view?.close();
199
+ }
200
+
201
+ /**
202
+ * Keeps the pill off a comment toolbar's bar. The bar's default place is the bottom centre and a person can
203
+ * drag it to either corner, so the pill is lifted above it whenever the two would touch. A sheet (the
204
+ * panel on a narrow screen) spans the window, so it is lifted above any bar near the bottom.
205
+ */
206
+ function clearToolbar() {
207
+ if (!root || !pill) return;
208
+ const bar = document.querySelector('#cs-root:not([data-cs-capture]) [data-cs-ui="bar"]');
209
+ let next = 0;
210
+ let sheet = 0;
211
+ if (bar) {
212
+ const box = bar.getBoundingClientRect();
213
+ const own = pill.getBoundingClientRect();
214
+ // Where the pill sits when nothing lifts it.
215
+ const top = own.top + lift;
216
+ const bottom = own.bottom + lift;
217
+ const touching =
218
+ box.width > 0 &&
219
+ own.width > 0 &&
220
+ box.left < own.right + CLEARANCE &&
221
+ box.right > own.left - CLEARANCE &&
222
+ box.top < bottom + CLEARANCE &&
223
+ box.bottom > top - CLEARANCE;
224
+ if (touching) next = Math.max(0, Math.ceil(bottom - box.top + CLEARANCE));
225
+ if (box.width > 0 && window.innerHeight - box.bottom < SHEET_ZONE) {
226
+ sheet = Math.max(0, Math.ceil(window.innerHeight - box.top + CLEARANCE));
227
+ }
228
+ }
229
+ if (next !== lift) {
230
+ lift = next;
231
+ root.style.setProperty("--cth-lift", `${lift}px`);
232
+ }
233
+ root.style.setProperty("--cth-sheet-lift", `${sheet}px`);
234
+ }
235
+
236
+ /** Looks again when a toolbar may have arrived, moved or changed size. Nothing runs on a timer. */
237
+ function watchToolbar(): () => void {
238
+ let frame = 0;
239
+ let settle: ReturnType<typeof setTimeout> | undefined;
240
+ const soon = () => {
241
+ cancelAnimationFrame(frame);
242
+ frame = requestAnimationFrame(clearToolbar);
243
+ // Once more when the bar has finished arriving or sliding to a corner.
244
+ clearTimeout(settle);
245
+ settle = setTimeout(clearToolbar, 450);
246
+ };
247
+ const arrivals = typeof MutationObserver === "function" ? new MutationObserver(soon) : null;
248
+ arrivals?.observe(document.body, { childList: true });
249
+ window.addEventListener("resize", soon);
250
+ window.addEventListener("pointerup", soon, true);
251
+ soon();
252
+ return () => {
253
+ cancelAnimationFrame(frame);
254
+ clearTimeout(settle);
255
+ arrivals?.disconnect();
256
+ window.removeEventListener("resize", soon);
257
+ window.removeEventListener("pointerup", soon, true);
258
+ };
259
+ }
260
+
261
+ function build() {
262
+ ensureHelpStyles(STYLE_ID, HELP_BASE_CSS);
263
+ root = document.createElement("div");
264
+ root.className = "cth";
265
+ root.setAttribute("data-cs-help", "");
266
+ // The capture script leaves this out of every picture and never offers it to point at.
267
+ root.setAttribute("data-cs-capture-ignore", "");
268
+ pill = document.createElement("button");
269
+ pill.type = "button";
270
+ pill.className = "cth-pill";
271
+ pill.setAttribute("data-cs-help-ui", "pill");
272
+ pill.setAttribute("aria-haspopup", "dialog");
273
+ pill.setAttribute("aria-expanded", "false");
274
+ const mark = document.createElement("span");
275
+ mark.className = "cth-mark";
276
+ mark.setAttribute("aria-hidden", "true");
277
+ mark.textContent = "?";
278
+ label = document.createElement("span");
279
+ label.className = "cth-label";
280
+ pill.append(mark, label);
281
+ pill.addEventListener("click", () => {
282
+ if (view?.isOpen) close();
283
+ else open();
284
+ });
285
+ pill.addEventListener("pointerenter", warm, { once: true });
286
+ pill.addEventListener("focus", warm, { once: true });
287
+ root.appendChild(pill);
288
+ document.body.appendChild(root);
289
+ stopWatching = watchToolbar();
290
+ }
291
+
292
+ function apply() {
293
+ if (!root || !label || !options) return;
294
+ const text = typeof options.label === "string" ? options.label.trim() : "";
295
+ label.textContent = text || DEFAULT_LABEL;
296
+ root.setAttribute(
297
+ "data-position",
298
+ POSITIONS.includes(options.position as HelpPosition)
299
+ ? (options.position as HelpPosition)
300
+ : "bottom-right",
301
+ );
302
+ root.setAttribute(
303
+ "data-theme",
304
+ THEMES.includes(options.theme as HelpTheme) ? (options.theme as HelpTheme) : "auto",
305
+ );
306
+ }
307
+
308
+ function unmount() {
309
+ wantOpen = false;
310
+ stopWatching?.();
311
+ stopWatching = null;
312
+ view?.destroy();
313
+ view = null;
314
+ root?.remove();
315
+ root = null;
316
+ pill = null;
317
+ label = null;
318
+ note = null;
319
+ lift = 0;
320
+ options = null;
321
+ }
322
+
323
+ return {
324
+ mount(given: HelpOptions): HelpHandle {
325
+ if (typeof given?.submit !== "function") {
326
+ throw new TypeError("CommentToolHelp.mount: submit must be a function.");
327
+ }
328
+ options = given;
329
+ if (!root) build();
330
+ apply();
331
+ view?.update();
332
+ clearToolbar();
333
+ return { open, close, unmount };
334
+ },
335
+ supportRequestsAt,
336
+ HelpError,
337
+ };
338
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * `<HelpPill>`: the "Need help?" pill for a host that installs the package. Renders nothing itself: once the
3
+ * page is idle it adds `<service>/v1/toolbar/help.js` and mounts the pill that script defines, so the pill a
4
+ * React host shows is the one a script-tag host shows, and both follow the service's deploys.
5
+ *
6
+ * It mounts with the component and leaves with it. What a person has written goes with it too, so put it
7
+ * where it stays for as long as the person is signed in, not inside a page that is swapped on navigation.
8
+ */
9
+ import { useEffect, useRef } from "react";
10
+ import { IDLE_TIMEOUT_MS } from "../runtime/boot.js";
11
+ import { whenIdle } from "../runtime/idle.js";
12
+ import { sessionFor } from "../runtime/session.js";
13
+ import type { CommentToolHelpApi, HelpHandle, HelpOptions } from "./types.js";
14
+
15
+ export type HelpPillProps = HelpOptions & {
16
+ /**
17
+ * Comment Tool's address: `https://comment-tool.example.com`. Left out, it is the one the session route
18
+ * names, which is how `<CommentToolbar>` learns it. That route says so only to people the comment toolbar
19
+ * is on for, so a host that shows the pill to everyone gives the address here.
20
+ */
21
+ serviceUrl?: string;
22
+ /** The session route asked when `serviceUrl` is left out. Default `/api/comment-service/session`. */
23
+ sessionPath?: string;
24
+ };
25
+
26
+ type Page = { CommentToolHelp?: Partial<CommentToolHelpApi> };
27
+
28
+ const loading = new Map<string, Promise<CommentToolHelpApi>>();
29
+
30
+ /** Adds `help.js` to the page once, or takes the one a script tag already put there. */
31
+ function loadHelp(serviceUrl: string): Promise<CommentToolHelpApi> {
32
+ const page = window as unknown as Page;
33
+ const there = () =>
34
+ typeof page.CommentToolHelp?.mount === "function"
35
+ ? (page.CommentToolHelp as CommentToolHelpApi)
36
+ : null;
37
+ const ready = there();
38
+ if (ready) return Promise.resolve(ready);
39
+ const url = `${serviceUrl.replace(/\/+$/, "")}/v1/toolbar/help.js`;
40
+ let kept = loading.get(url);
41
+ if (!kept) {
42
+ kept = new Promise<CommentToolHelpApi>((resolve, reject) => {
43
+ const tag = document.createElement("script");
44
+ tag.src = url;
45
+ tag.async = true;
46
+ tag.onload = () => {
47
+ const api = there();
48
+ if (api) resolve(api);
49
+ else reject(new Error(`${url} defined no CommentToolHelp.`));
50
+ };
51
+ tag.onerror = () => {
52
+ tag.remove();
53
+ reject(new Error(`${url} did not load.`));
54
+ };
55
+ document.head.appendChild(tag);
56
+ });
57
+ // A failed load is not kept: the next mount tries again.
58
+ kept.catch(() => loading.delete(url));
59
+ loading.set(url, kept);
60
+ }
61
+ return kept;
62
+ }
63
+
64
+ export function HelpPill(props: HelpPillProps) {
65
+ const {
66
+ serviceUrl,
67
+ sessionPath = "/api/comment-service/session",
68
+ label,
69
+ position,
70
+ theme,
71
+ } = props;
72
+ const hasList = typeof props.list === "function";
73
+ const hasMore = typeof props.moreOptions === "function";
74
+ const latest = useRef(props);
75
+ const mounted = useRef<{ api: CommentToolHelpApi; handle: HelpHandle } | null>(null);
76
+
77
+ /** The options as the pill gets them: the hooks always call the host's latest functions. */
78
+ const options = useRef((): HelpOptions => {
79
+ const now = latest.current;
80
+ return {
81
+ submit: (request) => latest.current.submit(request),
82
+ ...(typeof now.list === "function"
83
+ ? {
84
+ list: () => {
85
+ const list = latest.current.list;
86
+ return list ? list() : Promise.resolve([]);
87
+ },
88
+ }
89
+ : {}),
90
+ label: now.label,
91
+ position: now.position,
92
+ theme: now.theme,
93
+ captures: () => {
94
+ const captures = latest.current.captures;
95
+ return typeof captures === "function" ? captures() : captures !== false;
96
+ },
97
+ ...(typeof now.moreOptions === "function"
98
+ ? { moreOptions: (draft) => latest.current.moreOptions?.(draft) }
99
+ : {}),
100
+ };
101
+ });
102
+
103
+ useEffect(() => {
104
+ latest.current = props;
105
+ });
106
+
107
+ useEffect(() => {
108
+ let cancelled = false;
109
+ const start = async () => {
110
+ const address =
111
+ serviceUrl ?? (await sessionFor(sessionPath)().catch(() => null))?.apiUrl ?? null;
112
+ if (cancelled) return;
113
+ if (!address) {
114
+ console.info(
115
+ `[comment-service] <HelpPill> is not shown: ${sessionPath} names no service for this person. Pass serviceUrl to show it to everyone.`,
116
+ );
117
+ return;
118
+ }
119
+ const api = await loadHelp(address);
120
+ if (cancelled) return;
121
+ mounted.current = { api, handle: api.mount(options.current()) };
122
+ };
123
+ const cancelWait = whenIdle(() => {
124
+ start().catch((error: unknown) =>
125
+ console.warn("[comment-service] The help pill could not start.", error),
126
+ );
127
+ }, IDLE_TIMEOUT_MS);
128
+ return () => {
129
+ cancelled = true;
130
+ cancelWait();
131
+ mounted.current?.handle.unmount();
132
+ mounted.current = null;
133
+ };
134
+ }, [serviceUrl, sessionPath]);
135
+
136
+ // What the pill draws from changed: the same pill takes the new options, and a draft stays.
137
+ useEffect(() => {
138
+ const now = mounted.current;
139
+ if (now) now.handle = now.api.mount(options.current());
140
+ }, [label, position, theme, hasList, hasMore]);
141
+
142
+ return null;
143
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The pill's two hooks for a host whose server route is `handleSupportRequest` (`../server/support.ts`):
3
+ *
4
+ * CommentToolHelp.mount(CommentToolHelp.supportRequestsAt("/api/support"));
5
+ *
6
+ * `submit` posts the request as JSON with its pictures as base64; `list` reads the person's own requests.
7
+ * Both go to the host's own origin with its own cookies. A refusal's sentence, written by the route for the
8
+ * person, is passed on as a `HelpError`.
9
+ */
10
+ import {
11
+ HELP_LIMITS,
12
+ type HelpFailureWire,
13
+ type HelpImageWire,
14
+ type HelpReceiptWire,
15
+ type HelpRequestWire,
16
+ } from "./wire.js";
17
+ import {
18
+ HelpError,
19
+ type HelpImage,
20
+ type HelpReceipt,
21
+ type HelpRequest,
22
+ type SubmittedRequest,
23
+ type SupportRequests,
24
+ } from "./types.js";
25
+
26
+ const NOT_SENT = "That did not send. Check your connection and try again.";
27
+ const NOT_READ = "Your requests could not be loaded.";
28
+
29
+ async function base64Of(blob: Blob): Promise<string> {
30
+ const bytes = new Uint8Array(await blob.arrayBuffer());
31
+ let binary = "";
32
+ const chunk = 0x8000;
33
+ for (let index = 0; index < bytes.length; index += chunk) {
34
+ binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
35
+ }
36
+ return btoa(binary);
37
+ }
38
+
39
+ async function imageWire(image: HelpImage): Promise<HelpImageWire> {
40
+ if (image.blob.size > HELP_LIMITS.imageBytes) {
41
+ throw new HelpError("A picture is too large to send. Remove it and try again.");
42
+ }
43
+ return {
44
+ name: image.name,
45
+ contentType: image.blob.type,
46
+ width: image.width,
47
+ height: image.height,
48
+ markedUp: image.markedUp,
49
+ data: await base64Of(image.blob),
50
+ };
51
+ }
52
+
53
+ /** The route's own sentence, when it answered with one. */
54
+ async function sentenceOf(response: Response): Promise<string | null> {
55
+ const payload = (await response.json().catch(() => null)) as Partial<HelpFailureWire> | null;
56
+ return typeof payload?.error === "string" && payload.error.trim() ? payload.error : null;
57
+ }
58
+
59
+ export function supportRequestsAt(url: string): SupportRequests {
60
+ return {
61
+ async submit(request: HelpRequest): Promise<HelpReceipt> {
62
+ const body: HelpRequestWire = {
63
+ id: request.id,
64
+ message: request.message,
65
+ pageUrl: request.pageUrl,
66
+ route: request.route,
67
+ pointed: request.pointed,
68
+ images: await Promise.all(request.images.map(imageWire)),
69
+ client: request.client,
70
+ };
71
+ let response: Response;
72
+ try {
73
+ response = await fetch(url, {
74
+ method: "POST",
75
+ headers: { accept: "application/json", "content-type": "application/json" },
76
+ credentials: "same-origin",
77
+ cache: "no-store",
78
+ body: JSON.stringify(body),
79
+ });
80
+ } catch {
81
+ throw new HelpError(NOT_SENT);
82
+ }
83
+ if (!response.ok) throw new HelpError((await sentenceOf(response)) ?? NOT_SENT);
84
+ const receipt = (await response.json().catch(() => null)) as HelpReceiptWire | null;
85
+ return {
86
+ ...(typeof receipt?.reference === "string" ? { reference: receipt.reference } : {}),
87
+ ...(typeof receipt?.url === "string" ? { url: receipt.url } : {}),
88
+ ...(typeof receipt?.note === "string" ? { note: receipt.note } : {}),
89
+ };
90
+ },
91
+
92
+ async list(): Promise<readonly SubmittedRequest[]> {
93
+ let response: Response;
94
+ try {
95
+ response = await fetch(url, {
96
+ headers: { accept: "application/json" },
97
+ credentials: "same-origin",
98
+ cache: "no-store",
99
+ });
100
+ } catch {
101
+ throw new HelpError(NOT_READ);
102
+ }
103
+ if (!response.ok) throw new HelpError((await sentenceOf(response)) ?? NOT_READ);
104
+ const payload = (await response.json().catch(() => null)) as {
105
+ requests?: unknown;
106
+ } | null;
107
+ if (!Array.isArray(payload?.requests)) throw new HelpError(NOT_READ);
108
+ return payload.requests as SubmittedRequest[];
109
+ },
110
+ };
111
+ }