@devaccel-labs/app-comments-toolbar 1.1.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 (214) hide show
  1. package/README.md +148 -30
  2. package/dist/react.js +417 -64
  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 +7 -0
  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 +1 -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 +2 -0
  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 -3
  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 +64 -11
  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/screen.d.ts +47 -0
  82. package/dist/types/runtime/screenshot.d.ts +60 -8
  83. package/dist/types/runtime/session-source.d.ts +45 -0
  84. package/dist/types/runtime/session.d.ts +20 -18
  85. package/dist/types/runtime/shield.d.ts +9 -0
  86. package/dist/types/runtime/shot.d.ts +45 -0
  87. package/dist/types/runtime/sketch.d.ts +26 -0
  88. package/dist/types/runtime/styles.d.ts +1 -1
  89. package/dist/types/runtime/sync.d.ts +1 -0
  90. package/dist/types/runtime/thread-model.d.ts +18 -0
  91. package/dist/types/runtime/ticket-form-model.d.ts +57 -0
  92. package/dist/types/runtime/ticket-form.d.ts +9 -0
  93. package/dist/types/runtime/toolbar.d.ts +37 -1
  94. package/dist/types/runtime/ui-scale.d.ts +17 -0
  95. package/dist/types/runtime/update.d.ts +32 -8
  96. package/dist/types/runtime/use-debug-context.d.ts +27 -0
  97. package/dist/types/server/index.d.ts +22 -0
  98. package/dist/types/server/support.d.ts +20 -0
  99. package/dist/types/server.d.ts +2 -0
  100. package/dist/types/vite/plugin.d.ts +15 -0
  101. package/dist/vite.js +121 -41
  102. package/package.json +24 -11
  103. package/src/capture/api.ts +128 -0
  104. package/src/capture/editor.tsx +178 -0
  105. package/src/capture/ignore.ts +38 -0
  106. package/src/capture/index.ts +27 -0
  107. package/src/capture/picker.tsx +171 -0
  108. package/src/capture/picture.ts +90 -0
  109. package/src/capture/surface.tsx +185 -0
  110. package/src/compiler/annotate.ts +202 -0
  111. package/src/compiler/babel.d.ts +11 -0
  112. package/src/compiler/enabled.ts +55 -0
  113. package/src/compiler/index.ts +2 -0
  114. package/src/core/index.ts +8 -0
  115. package/src/core/resolve.ts +307 -0
  116. package/src/core/types.ts +58 -0
  117. package/src/debug-context.ts +47 -0
  118. package/src/dictation.ts +17 -0
  119. package/src/embed/controller.ts +215 -0
  120. package/src/embed/index.ts +68 -0
  121. package/src/embed/options.ts +100 -0
  122. package/src/help/index.ts +38 -0
  123. package/src/help/panel-index.ts +17 -0
  124. package/src/help/panel-styles.ts +123 -0
  125. package/src/help/panel.ts +840 -0
  126. package/src/help/pill-styles.ts +75 -0
  127. package/src/help/pill.ts +338 -0
  128. package/src/help/react.tsx +143 -0
  129. package/src/help/requests.ts +111 -0
  130. package/src/help/types.ts +124 -0
  131. package/src/help/wire.ts +55 -0
  132. package/src/native-wire.ts +124 -0
  133. package/src/native.ts +282 -0
  134. package/src/react.ts +27 -0
  135. package/src/replay.ts +13 -0
  136. package/src/runtime/api.ts +531 -0
  137. package/src/runtime/attachment-model.ts +42 -0
  138. package/src/runtime/attachments.tsx +1260 -0
  139. package/src/runtime/boot.ts +95 -0
  140. package/src/runtime/browser-label.ts +31 -0
  141. package/src/runtime/build-id.ts +10 -0
  142. package/src/runtime/capture-ui.tsx +306 -0
  143. package/src/runtime/capture.ts +508 -0
  144. package/src/runtime/clarity-model.ts +634 -0
  145. package/src/runtime/clarity-ui.tsx +454 -0
  146. package/src/runtime/client.ts +151 -0
  147. package/src/runtime/composer-size.tsx +146 -0
  148. package/src/runtime/dashboard.tsx +1130 -0
  149. package/src/runtime/debug-context-core.ts +681 -0
  150. package/src/runtime/debug-context-host.ts +56 -0
  151. package/src/runtime/debug-context-model.ts +1319 -0
  152. package/src/runtime/debug-context-redact.ts +259 -0
  153. package/src/runtime/debug-context.ts +613 -0
  154. package/src/runtime/details-model.ts +30 -0
  155. package/src/runtime/details.tsx +395 -0
  156. package/src/runtime/dictation-engine.ts +616 -0
  157. package/src/runtime/dictation-model.ts +79 -0
  158. package/src/runtime/dictation-ui.tsx +363 -0
  159. package/src/runtime/dictation.ts +271 -0
  160. package/src/runtime/dock.ts +108 -0
  161. package/src/runtime/draft-dock.tsx +124 -0
  162. package/src/runtime/draft-model.ts +251 -0
  163. package/src/runtime/draft-store.ts +149 -0
  164. package/src/runtime/edits.tsx +165 -0
  165. package/src/runtime/font.ts +42 -0
  166. package/src/runtime/format.ts +129 -0
  167. package/src/runtime/freshness.ts +23 -0
  168. package/src/runtime/handoff.ts +76 -0
  169. package/src/runtime/host-watch.ts +64 -0
  170. package/src/runtime/icons.tsx +295 -0
  171. package/src/runtime/idle.ts +20 -0
  172. package/src/runtime/index.ts +11 -0
  173. package/src/runtime/ink.ts +6 -0
  174. package/src/runtime/kind-model.ts +20 -0
  175. package/src/runtime/live-model.ts +32 -0
  176. package/src/runtime/loader.tsx +87 -0
  177. package/src/runtime/markup-editor.tsx +1333 -0
  178. package/src/runtime/markup-model.ts +485 -0
  179. package/src/runtime/markup.ts +382 -0
  180. package/src/runtime/members.ts +58 -0
  181. package/src/runtime/mention-parse.ts +56 -0
  182. package/src/runtime/mentions.tsx +205 -0
  183. package/src/runtime/page-background.ts +51 -0
  184. package/src/runtime/page-image.ts +1592 -0
  185. package/src/runtime/placement.ts +336 -0
  186. package/src/runtime/record.ts +402 -0
  187. package/src/runtime/redact.ts +30 -0
  188. package/src/runtime/related.ts +34 -0
  189. package/src/runtime/render-page.ts +2 -0
  190. package/src/runtime/replay-model.ts +232 -0
  191. package/src/runtime/replay.ts +173 -0
  192. package/src/runtime/runtime-version.ts +7 -0
  193. package/src/runtime/runtime.tsx +51 -0
  194. package/src/runtime/screen.ts +182 -0
  195. package/src/runtime/screenshot.ts +260 -0
  196. package/src/runtime/session-source.ts +112 -0
  197. package/src/runtime/session.ts +108 -0
  198. package/src/runtime/shield.ts +34 -0
  199. package/src/runtime/shot.tsx +422 -0
  200. package/src/runtime/sketch.ts +139 -0
  201. package/src/runtime/styles.ts +1541 -0
  202. package/src/runtime/sync.ts +300 -0
  203. package/src/runtime/thread-model.ts +71 -0
  204. package/src/runtime/ticket-form-model.ts +128 -0
  205. package/src/runtime/ticket-form.tsx +342 -0
  206. package/src/runtime/toolbar.tsx +4427 -0
  207. package/src/runtime/ui-scale.ts +75 -0
  208. package/src/runtime/update.ts +98 -0
  209. package/src/runtime/use-debug-context.ts +81 -0
  210. package/src/server/index.ts +133 -0
  211. package/src/server/support.ts +499 -0
  212. package/src/server.ts +4 -0
  213. package/src/vite/plugin.ts +102 -0
  214. package/src/vite.ts +1 -0
@@ -0,0 +1,454 @@
1
+ import {
2
+ Fragment,
3
+ useCallback,
4
+ useEffect,
5
+ useId,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type KeyboardEvent as ReactKeyboardEvent,
10
+ } from "react";
11
+ import { checkClarity } from "./api.js";
12
+ import {
13
+ answerRoom,
14
+ askClarity,
15
+ canSendAsIs,
16
+ CLARITY_MAX_ROUNDS,
17
+ CLARITY_WORDS,
18
+ clarityAfterCheck,
19
+ clarityAnnouncement,
20
+ clarityBody,
21
+ clarityChecks,
22
+ clarityGroups,
23
+ clarityReader,
24
+ clarityStep,
25
+ openQuestions,
26
+ passMark,
27
+ whoWouldAsk,
28
+ withAnswer,
29
+ type ClarityFacts,
30
+ type ClarityHold,
31
+ type ClarityMode,
32
+ type ClarityQuestion,
33
+ type ClaritySend,
34
+ } from "./clarity-model.js";
35
+ import type { DictationDeps } from "./dictation-engine.js";
36
+ import { DictateButton, isDictationKey, useDictation } from "./dictation-ui.js";
37
+ import type { Session } from "./session.js";
38
+
39
+ /** What Send does next: post this text, or wait (the questions are on screen). */
40
+ export type ClaritySent = { readonly post: true; readonly body: string } | { readonly post: false };
41
+
42
+ export type ClarityControl = ReturnType<typeof useClarity>;
43
+
44
+ /**
45
+ * The clarity check for one composer (`./clarity-model.ts` decides; this holds the state). `send` is what the
46
+ * composer's submit asks before it posts a new comment. It always resolves, within the model's wait, and
47
+ * never rejects: when the check can't answer, the comment is posted as it would have been without it.
48
+ */
49
+ export function useClarity(
50
+ session: Session | null,
51
+ member: string | null,
52
+ mode: ClarityMode | null,
53
+ ) {
54
+ const [hold, setHold] = useState<ClarityHold | null>(null);
55
+ const [checking, setChecking] = useState(false);
56
+ /** `require`, and Send was pressed with nothing added: the hint says what to do. */
57
+ const [stayed, setStayed] = useState(false);
58
+ /** Counts the times questions arrived or stayed, so focus goes to the first one each time. */
59
+ const [arrived, setArrived] = useState(0);
60
+ const [dictating, setDictating] = useState<readonly string[]>([]);
61
+ // What `send` reads: it runs across an await, and a re-render must not be what it waits for.
62
+ const latest = useRef<ClarityHold | null>(null);
63
+ const passed = useRef<string | null>(null);
64
+ const asIs = useRef(false);
65
+
66
+ const put = useCallback((next: ClarityHold | null) => {
67
+ latest.current = next;
68
+ setHold(next);
69
+ }, []);
70
+
71
+ /** A new draft, or none: nothing is held and nothing was asked. */
72
+ const reset = useCallback(() => {
73
+ put(null);
74
+ passed.current = null;
75
+ asIs.current = false;
76
+ setStayed(false);
77
+ setDictating([]);
78
+ }, [put]);
79
+
80
+ /** A draft picked up from storage, with the questions and answers it was put away with. */
81
+ const restore = useCallback(
82
+ (found: ClarityHold | null) => {
83
+ put(found);
84
+ passed.current = null;
85
+ asIs.current = false;
86
+ setStayed(false);
87
+ },
88
+ [put],
89
+ );
90
+
91
+ const answer = useCallback(
92
+ (questionId: string, text: string) => {
93
+ if (!latest.current) return;
94
+ put(withAnswer(latest.current, questionId, text));
95
+ setStayed(false);
96
+ },
97
+ [put],
98
+ );
99
+
100
+ const noteDictating = useCallback((questionId: string, on: boolean) => {
101
+ setDictating((current) => {
102
+ const has = current.includes(questionId);
103
+ if (on === has) return current;
104
+ return on ? [...current, questionId] : current.filter((id) => id !== questionId);
105
+ });
106
+ }, []);
107
+
108
+ /** "Send as is" was pressed: the submit that follows posts without asking again. */
109
+ const chooseAsIs = useCallback(() => {
110
+ asIs.current = true;
111
+ }, []);
112
+ /** How this submit was asked for. Read once per submit, first thing, so a submit that bails clears it. */
113
+ const takeSend = useCallback((): ClaritySend => {
114
+ const send = asIs.current ? "as-is" : "send";
115
+ asIs.current = false;
116
+ return send;
117
+ }, []);
118
+
119
+ const send = useCallback(
120
+ async (facts: ClarityFacts, how: ClaritySend): Promise<ClaritySent> => {
121
+ const before = latest.current;
122
+ const go = (): ClaritySent => ({ post: true, body: clarityBody(facts.body, before) });
123
+ const step = clarityStep({
124
+ mode,
125
+ hold: before,
126
+ draft: facts,
127
+ send: how,
128
+ passed: passed.current,
129
+ });
130
+ switch (step.kind) {
131
+ case "post":
132
+ return go();
133
+ case "stay":
134
+ setStayed(true);
135
+ setArrived((count) => count + 1);
136
+ return { post: false };
137
+ case "check": {
138
+ if (!session || !member) return go();
139
+ setChecking(true);
140
+ const outcome = await askClarity((signal) =>
141
+ checkClarity(session, member, step.request, signal),
142
+ );
143
+ setChecking(false);
144
+ const next = clarityAfterCheck(before, facts, step.request, outcome);
145
+ if (!next) {
146
+ // If the post itself fails, pressing Send again posts; it does not ask a second time.
147
+ passed.current = passMark(facts, before);
148
+ return go();
149
+ }
150
+ put(next);
151
+ setStayed(false);
152
+ setArrived((count) => count + 1);
153
+ return { post: false };
154
+ }
155
+ default: {
156
+ const never: never = step;
157
+ return never;
158
+ }
159
+ }
160
+ },
161
+ [member, mode, put, session],
162
+ );
163
+
164
+ return useMemo(
165
+ () => ({
166
+ hold,
167
+ /** The service is being asked: Send shows it is working. */
168
+ checking,
169
+ stayed,
170
+ arrived,
171
+ /** An answer is still being dictated: sending now would lose words. */
172
+ dictating: dictating.length > 0,
173
+ send,
174
+ takeSend,
175
+ chooseAsIs,
176
+ answer,
177
+ noteDictating,
178
+ reset,
179
+ restore,
180
+ }),
181
+ [
182
+ answer,
183
+ arrived,
184
+ checking,
185
+ chooseAsIs,
186
+ dictating,
187
+ hold,
188
+ noteDictating,
189
+ reset,
190
+ restore,
191
+ send,
192
+ stayed,
193
+ takeSend,
194
+ ],
195
+ );
196
+ }
197
+
198
+ type AnswerKeys = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => void;
199
+
200
+ type Voice = {
201
+ readonly transcribe: DictationDeps["transcribe"];
202
+ /** People who may be named, so they are spelled right. */
203
+ readonly names: readonly string[];
204
+ readonly onNote: (note: string) => void;
205
+ /** Dictation can't start now (a post or a screen recording is running). */
206
+ readonly off: boolean;
207
+ };
208
+
209
+ function ClarityAnswerBox({
210
+ question,
211
+ value,
212
+ note,
213
+ room,
214
+ locked,
215
+ voice,
216
+ onChange,
217
+ onDictating,
218
+ onKeyDown,
219
+ }: {
220
+ question: ClarityQuestion;
221
+ value: string;
222
+ note: string | undefined;
223
+ /** The longest this answer may be (`answerRoom`). */
224
+ room: number;
225
+ /** The service is reading the answers: they are not changed under it. */
226
+ locked: boolean;
227
+ voice: Voice;
228
+ onChange: (text: string) => void;
229
+ onDictating: (questionId: string, on: boolean) => void;
230
+ onKeyDown: AnswerKeys;
231
+ }) {
232
+ const id = useId();
233
+ const field = `clarity-answer-${question.id}`;
234
+ // The composer's own dictation, pointed at this box. No voice file is kept: the words are the answer.
235
+ const dictation = useDictation({
236
+ transcribe: voice.transcribe,
237
+ value,
238
+ onChange,
239
+ names: voice.names,
240
+ onNote: voice.onNote,
241
+ });
242
+ const writing = dictation.busy;
243
+ useEffect(() => {
244
+ onDictating(question.id, writing);
245
+ }, [onDictating, question.id, writing]);
246
+ useEffect(() => () => onDictating(question.id, false), [onDictating, question.id]);
247
+ // Also when there was never any room: a box that takes nothing says why.
248
+ const full = value.length >= room;
249
+ const described = [
250
+ question.why ? `${id}-why` : null,
251
+ note ? `${id}-note` : null,
252
+ full ? `${id}-full` : null,
253
+ ]
254
+ .filter(Boolean)
255
+ .join(" ");
256
+ return (
257
+ <li className="cs-clarity-item" data-cs-ui="clarity-question" data-question-id={question.id}>
258
+ <label className="cs-clarity-ask" htmlFor={id}>
259
+ {question.ask}
260
+ </label>
261
+ {question.why ? (
262
+ <span className="cs-clarity-why" id={`${id}-why`}>
263
+ {question.why}
264
+ </span>
265
+ ) : null}
266
+ <div className="cs-clarity-answer">
267
+ <textarea
268
+ id={id}
269
+ data-cs-ui={field}
270
+ data-question-id={question.id}
271
+ rows={1}
272
+ placeholder={CLARITY_WORDS.placeholder}
273
+ value={value}
274
+ maxLength={room}
275
+ readOnly={locked}
276
+ aria-describedby={described || undefined}
277
+ onChange={(event) => onChange(event.target.value)}
278
+ onKeyDown={(event) => {
279
+ if (isDictationKey(event)) {
280
+ event.preventDefault();
281
+ if (!voice.off && !locked) dictation.toggle(event.currentTarget);
282
+ return;
283
+ }
284
+ if (event.key === "Escape" && dictation.listening) {
285
+ // Escape ends the take, as it does in the comment box; it does not close the composer.
286
+ event.preventDefault();
287
+ event.stopPropagation();
288
+ dictation.stop();
289
+ return;
290
+ }
291
+ onKeyDown(event);
292
+ }}
293
+ />
294
+ <DictateButton dictation={dictation} field={field} disabled={voice.off || locked} compact />
295
+ </div>
296
+ {note ? (
297
+ <p className="cs-clarity-note" id={`${id}-note`} data-cs-ui="clarity-note">
298
+ {note}
299
+ </p>
300
+ ) : null}
301
+ {full ? (
302
+ <p className="cs-clarity-full" id={`${id}-full`} data-cs-ui="clarity-full">
303
+ {CLARITY_WORDS.full}
304
+ </p>
305
+ ) : null}
306
+ </li>
307
+ );
308
+ }
309
+
310
+ /** What the check took as given instead of asking. Shown so a wrong one is seen; never asked about. */
311
+ function TakenAsGiven({ assumed }: { assumed: readonly string[] }) {
312
+ const id = useId();
313
+ return (
314
+ <div className="cs-clarity-given" data-cs-ui="clarity-assumed">
315
+ <p className="cs-clarity-given-head" id={id}>
316
+ {CLARITY_WORDS.assumed}
317
+ </p>
318
+ <ul aria-labelledby={id}>
319
+ {assumed.map((sentence) => (
320
+ <li key={sentence}>{sentence}</li>
321
+ ))}
322
+ </ul>
323
+ <p className="cs-clarity-given-note">{CLARITY_WORDS.assumedNote}</p>
324
+ </div>
325
+ );
326
+ }
327
+
328
+ /**
329
+ * What the composer shows under the comment while the check holds it: what a reader takes from the comment,
330
+ * what the check took as given, each open question with why a reader needs it and its own answer box, grouped
331
+ * by who would ask when a developer and whoever tests the fix both would, and "Send as is" when skipping is
332
+ * allowed. Focus goes to the first question when they arrive, and a polite live region says they are there.
333
+ *
334
+ * Renders nothing at all where the service has no check, so nothing about those composers changes.
335
+ */
336
+ export function ClarityQuestions({
337
+ clarity,
338
+ mode,
339
+ body,
340
+ busy,
341
+ voice,
342
+ onKeyDown,
343
+ }: {
344
+ clarity: ClarityControl;
345
+ mode: ClarityMode | null;
346
+ /** The comment's text as it stands, for how much room each answer has. */
347
+ body: string;
348
+ /** A post or a check is running. */
349
+ busy: boolean;
350
+ voice: Voice;
351
+ /** The comment box's own key rules (Cmd+Enter sends), so the answer boxes follow them. */
352
+ onKeyDown: AnswerKeys;
353
+ }) {
354
+ const { hold, arrived, stayed, answer, chooseAsIs, noteDictating } = clarity;
355
+ const panel = useRef<HTMLElement>(null);
356
+ const leadId = useId();
357
+ // Only when questions arrive (or stay) under an open composer. A draft opened with questions already on it
358
+ // (from the dock, after a reload) keeps the focus every draft opens with.
359
+ const focused = useRef(arrived);
360
+ useEffect(() => {
361
+ if (arrived === focused.current) return;
362
+ focused.current = arrived;
363
+ panel.current?.querySelector<HTMLTextAreaElement>("textarea")?.focus();
364
+ }, [arrived]);
365
+ if (!hold && !clarityChecks(mode)) return null;
366
+ const open = hold ? openQuestions(hold) : [];
367
+ const groups = clarityGroups(open);
368
+ const grouped = groups.length > 1;
369
+ return (
370
+ <>
371
+ {hold ? (
372
+ <section
373
+ ref={panel}
374
+ className="cs-clarity"
375
+ data-cs-ui="clarity"
376
+ data-cs-round={hold.round}
377
+ aria-label={CLARITY_WORDS.region}
378
+ >
379
+ {hold.understood ? (
380
+ <p className="cs-clarity-read" data-cs-ui="clarity-understood">
381
+ <span>{CLARITY_WORDS.understood}</span> {hold.understood}
382
+ </p>
383
+ ) : null}
384
+ {hold.assumed?.length ? <TakenAsGiven assumed={hold.assumed} /> : null}
385
+ <p className="cs-clarity-lead" id={leadId} data-cs-ui="clarity-lead">
386
+ {`${whoWouldAsk(open)} would still have to ask:`}
387
+ </p>
388
+ {groups.map((group) => (
389
+ <Fragment key={group.audience}>
390
+ {grouped ? (
391
+ <p
392
+ className="cs-clarity-group"
393
+ id={`${leadId}-${group.audience}`}
394
+ data-cs-ui="clarity-group"
395
+ >
396
+ {`${clarityReader(group.audience)} would ask`}
397
+ </p>
398
+ ) : null}
399
+ <ol
400
+ className="cs-clarity-list"
401
+ data-audience={group.audience}
402
+ aria-labelledby={grouped ? `${leadId}-${group.audience}` : leadId}
403
+ >
404
+ {group.questions.map((question) => (
405
+ <ClarityAnswerBox
406
+ key={question.id}
407
+ question={question}
408
+ value={hold.answers[question.id] ?? ""}
409
+ note={hold.notes[question.id]}
410
+ room={answerRoom(body, hold, question.id)}
411
+ locked={busy}
412
+ voice={voice}
413
+ onChange={(text) => answer(question.id, text)}
414
+ onDictating={noteDictating}
415
+ onKeyDown={onKeyDown}
416
+ />
417
+ ))}
418
+ </ol>
419
+ </Fragment>
420
+ ))}
421
+ <div className="cs-clarity-foot">
422
+ <span className="cs-clarity-hint" data-cs-ui="clarity-hint">
423
+ {stayed
424
+ ? CLARITY_WORDS.stay
425
+ : hold.round >= CLARITY_MAX_ROUNDS
426
+ ? CLARITY_WORDS.free
427
+ : CLARITY_WORDS.hint}
428
+ </span>
429
+ {canSendAsIs(hold) ? (
430
+ <button
431
+ // Not a submit button: Enter in one of the form's fields must keep pressing Send, not this.
432
+ type="button"
433
+ className="cs-clarity-skip"
434
+ data-cs-ui="clarity-send-as-is"
435
+ disabled={busy}
436
+ onClick={(event) => {
437
+ chooseAsIs();
438
+ event.currentTarget.form?.requestSubmit();
439
+ }}
440
+ >
441
+ {CLARITY_WORDS.sendAsIs}
442
+ </button>
443
+ ) : null}
444
+ </div>
445
+ </section>
446
+ ) : null}
447
+ {/* Always in the composer, empty until there is something to say: a live region is only heard when it
448
+ was there before its words. */}
449
+ <p className="cs-clarity-live" role="status" aria-live="polite" data-cs-ui="clarity-live">
450
+ {clarityAnnouncement(hold, stayed, whoWouldAsk(open))}
451
+ </p>
452
+ </>
453
+ );
454
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The comment service client for the web toolbar: the DOM-free API (`./api.ts`) plus what only a browser page
3
+ * has — `localStorage` member tokens, and the sign-in window's relay back to this page.
4
+ */
5
+ import {
6
+ base64Url,
7
+ memberIsCurrent,
8
+ msfAuthorizeUrl,
9
+ msfHandoffFrom,
10
+ storageKey,
11
+ type MsfHandoff,
12
+ } from "./api.js";
13
+ import { readJwt, type Session } from "./session.js";
14
+
15
+ export * from "./api.js";
16
+
17
+ const storagePrefix = "devaccel.comment.";
18
+
19
+ /**
20
+ * When the comment service moves to a new URL, the saved sign-in is under the old URL's key. Copy the newest current
21
+ * one for this project and environment to the new key, and leave the old key in place so going back also works.
22
+ */
23
+ export function adoptStoredMember(
24
+ session: Pick<Session, "apiUrl" | "project" | "environment">,
25
+ storage: Storage = localStorage,
26
+ ) {
27
+ const key = storageKey(session);
28
+ if (storage.getItem(key)) return;
29
+ const suffix = `.${session.project}.${session.environment}`;
30
+ let best: { key: string; token: string; exp: number } | null = null;
31
+ for (let index = 0; index < storage.length; index++) {
32
+ const candidate = storage.key(index);
33
+ if (
34
+ !candidate ||
35
+ candidate === key ||
36
+ !candidate.startsWith(storagePrefix) ||
37
+ !candidate.endsWith(suffix)
38
+ )
39
+ continue;
40
+ const apiUrl = candidate.slice(storagePrefix.length, candidate.length - suffix.length);
41
+ if (!/^https?:\/\/[^/]+$/.test(apiUrl)) continue;
42
+ const token = storage.getItem(candidate);
43
+ if (!token || !memberIsCurrent(token)) continue;
44
+ const exp = readJwt(token)?.exp ?? 0;
45
+ if (!best || exp > best.exp) best = { key: candidate, token, exp };
46
+ }
47
+ if (!best) return;
48
+ storage.setItem(key, best.token);
49
+ }
50
+
51
+ function randomUrlSafe(bytes: number) {
52
+ return base64Url(crypto.getRandomValues(new Uint8Array(bytes)));
53
+ }
54
+
55
+ /**
56
+ * An OAuth 2.0 authorization request with PKCE (S256). The verifier never leaves this page. The sign-in window
57
+ * comes back to `returnTo` (this page, on this origin) with the code, so a code only ever reaches the app it
58
+ * was issued for.
59
+ */
60
+ export async function msfAuthorization(authorizeUrl: string, returnTo: string) {
61
+ const state = randomUrlSafe(32);
62
+ const verifier = randomUrlSafe(48);
63
+ const challenge = base64Url(
64
+ new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))),
65
+ );
66
+ const page = new URL(returnTo);
67
+ page.hash = "";
68
+ return { url: msfAuthorizeUrl(authorizeUrl, page.toString(), state, challenge), state, verifier };
69
+ }
70
+
71
+ const msfHandoffKey = "devaccel.comment.msf-handoff";
72
+ const msfChannel = "devaccel-comments-msf";
73
+
74
+ /**
75
+ * The sign-in window ends on this app's page with the result in the address hash (when it couldn't message the
76
+ * page that opened it). Pass it to that page through same-origin storage and a broadcast, and clean the address.
77
+ */
78
+ export function relayMsfHandoff(): boolean {
79
+ if (typeof window === "undefined") return false;
80
+ const hash = new URLSearchParams(window.location.hash.slice(1));
81
+ const handoff = msfHandoffFrom(window.location.hash);
82
+ if (!handoff) return false;
83
+ try {
84
+ localStorage.setItem(msfHandoffKey, JSON.stringify(handoff));
85
+ } catch {
86
+ // storage unavailable: the broadcast below still reaches open tabs
87
+ }
88
+ try {
89
+ const channel = new BroadcastChannel(msfChannel);
90
+ channel.postMessage(handoff);
91
+ channel.close();
92
+ } catch {
93
+ // no BroadcastChannel
94
+ }
95
+ for (const name of ["cs-msf-state", "cs-msf-code", "cs-msf-error"]) hash.delete(name);
96
+ const url = new URL(window.location.href);
97
+ url.hash = hash.toString();
98
+ history.replaceState(history.state, "", url);
99
+ return true;
100
+ }
101
+
102
+ /** Calls `onHandoff` when a sign-in window hands a result back, by message, broadcast or storage. */
103
+ export function listenForMsfHandoff(apiOrigin: string, onHandoff: (handoff: MsfHandoff) => void) {
104
+ const fromMessage = (event: MessageEvent) => {
105
+ const data = event.data as {
106
+ type?: string;
107
+ state?: string;
108
+ code?: string;
109
+ error?: string;
110
+ } | null;
111
+ if (
112
+ event.origin !== apiOrigin ||
113
+ data?.type !== "devaccel-comments:msf-sign-in" ||
114
+ typeof data.state !== "string"
115
+ )
116
+ return;
117
+ onHandoff({
118
+ state: data.state,
119
+ ...(data.code ? { code: data.code } : {}),
120
+ ...(data.error ? { error: data.error } : {}),
121
+ at: Date.now(),
122
+ });
123
+ };
124
+ const fromStorage = (event: StorageEvent) => {
125
+ if (event.key !== msfHandoffKey || !event.newValue) return;
126
+ try {
127
+ onHandoff(JSON.parse(event.newValue) as MsfHandoff);
128
+ } catch {
129
+ // ignore malformed
130
+ }
131
+ };
132
+ let channel: BroadcastChannel | null = null;
133
+ try {
134
+ channel = new BroadcastChannel(msfChannel);
135
+ channel.onmessage = (event) => onHandoff(event.data as MsfHandoff);
136
+ } catch {
137
+ channel = null;
138
+ }
139
+ window.addEventListener("message", fromMessage);
140
+ window.addEventListener("storage", fromStorage);
141
+ return () => {
142
+ window.removeEventListener("message", fromMessage);
143
+ window.removeEventListener("storage", fromStorage);
144
+ channel?.close();
145
+ try {
146
+ localStorage.removeItem(msfHandoffKey);
147
+ } catch {
148
+ // ignore
149
+ }
150
+ };
151
+ }