helldots 0.5.0 → 0.7.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.
package/README.md CHANGED
@@ -53,6 +53,51 @@ overlay.loadComments(stored);
53
53
  Every comment is plain JSON — pass `serializeComments()` output straight to
54
54
  your API and hand it back to `loadComments()` later.
55
55
 
56
+ `loadComments()` is safe to call at any point: made before the widget has
57
+ mounted, the data is held and applied at mount — though the counts come back
58
+ as zeroes, because nothing has been resolved against the DOM yet. Use
59
+ `onReady` when you want them:
60
+
61
+ ```js
62
+ createCommentOverlay({
63
+ onReady: async (overlay) => {
64
+ const { orphaned } = overlay.loadComments(await api.get("/comments"));
65
+ if (orphaned) console.info(`${orphaned} comments lost their element`);
66
+ },
67
+ });
68
+ ```
69
+
70
+ ### Loading only the comment in a link
71
+
72
+ A corpus too big to ship on every page load can be fetched per page — but
73
+ then a shared "Copy link" URL points at a comment that is not in the set.
74
+ `onCommentRequested` fires for exactly that case, once per id:
75
+
76
+ ```js
77
+ const overlay = createCommentOverlay({
78
+ onCommentRequested: async (id) => {
79
+ overlay.loadComments([await api.get(`/comments/${id}`)]);
80
+ },
81
+ });
82
+
83
+ overlay.loadComments(await api.get(`/comments?page=${location.pathname}`));
84
+ ```
85
+
86
+ Return a promise and the link is retried once it settles — the inbox opens on
87
+ the comment as soon as it lands. Until then it says the comment was not
88
+ found, rather than doing nothing.
89
+
90
+ To read the id yourself before any of this exists — to fetch that one comment
91
+ and nothing else — the package exports the reader the widget uses, so the
92
+ parameter name never has to be written twice:
93
+
94
+ ```js
95
+ import { readCommentLinkParam, DEFAULT_LINK_PARAM } from "helldots";
96
+
97
+ const id = readCommentLinkParam(); // defaults to DEFAULT_LINK_PARAM
98
+ const only = id ? [await api.get(`/comments/${id}`)] : [];
99
+ ```
100
+
56
101
  ### Server-rendered apps
57
102
 
58
103
  Importing the package on the server is safe — nothing touches the DOM at
@@ -141,6 +186,101 @@ Three ways out, cheapest first:
141
186
  - **Leave it.** Captures of such a page stay misaligned where text is
142
187
  concerned; everything else about them is correct.
143
188
 
189
+ ### Keeping screenshots out of your database
190
+
191
+ Every image is stored as a base64 data URL inside the record — around 33 KB
192
+ for the automatic capture alone. That is the first thing shed when
193
+ localStorage hits its quota, and in your own backend it means a 33 KB string
194
+ per comment in whatever column holds the JSON.
195
+
196
+ `transformScreenshot` is where you swap it for a URL:
197
+
198
+ ```js
199
+ createCommentOverlay({
200
+ transformScreenshot: async (dataUrl, { kind, commentId }) => {
201
+ const blob = await (await fetch(dataUrl)).blob();
202
+ const { url } = await api.upload(blob, { kind, commentId });
203
+ return url; // stored in place of the data URL
204
+ },
205
+ });
206
+ ```
207
+
208
+ It runs for every image the widget acquires: the automatic capture
209
+ (`kind: "context"`), a drag-crop region, and anything attached through the
210
+ file picker on a comment or a reply (`kind: "attachment"`). The two kinds
211
+ exist so the disposable one and the deliberate one can go to different
212
+ buckets.
213
+
214
+ **It runs at two different moments, and `kind` does not tell them apart.**
215
+ Everything on a comment transforms when the comment is saved; an attachment
216
+ on a _reply_ transforms when the file is picked, because `addReply()` is
217
+ synchronous and cannot wait on your upload. So a reply attachment can be
218
+ uploaded and then never referenced — the user closes the popover without
219
+ sending — and a comment can do the same in a narrower window, by being
220
+ dismissed while its upload is still in flight. Sweep for unreferenced blobs;
221
+ a URL you were handed is not a promise that a record will point at it.
222
+
223
+ It is **fail-open**. If your upload rejects — or resolves to anything that is
224
+ not a non-empty string — the original data URL is kept and you get
225
+ `onError(error, "transform")`. You receive a large record rather than losing
226
+ somebody's comment.
227
+
228
+ Not called for records you pass to `loadComments()`, nor for screenshots you
229
+ hand to `addReply()` yourself: in both cases the strings are already yours.
230
+
231
+ ## Identity
232
+
233
+ HellDots authenticates nobody. It takes whoever your app says is signed in
234
+ and records that:
235
+
236
+ ```js
237
+ createCommentOverlay({
238
+ user: { name: currentUser.fullName, id: currentUser.id },
239
+ });
240
+ ```
241
+
242
+ `name` is the display name — it is what appears on every comment and reply.
243
+ `id` is optional, never rendered, and persisted as `authorId` on everything
244
+ that user creates. Pass it whenever two people on your team can share a
245
+ display name: without it they are indistinguishable in the record, and they
246
+ share one reaction.
247
+
248
+ ```js
249
+ overlay.serializeComments()[0];
250
+ // { author: "Ana Pérez", authorId: "u_42", ... }
251
+ ```
252
+
253
+ Both fields ride along in `serializeComments()` output, on comments and on
254
+ replies alike. **The display name travels with the record**, so a store that
255
+ holds nothing but comments — a database of its own, with no users table —
256
+ renders every author and every audit entry without a single lookup back into
257
+ your app. The id is opaque to HellDots: point it at your user table, at a
258
+ comments-only store, or at nothing. `authorId` is `null` when you pass no
259
+ `id`, and on records written before it existed — the field is additive, so no
260
+ stored corpus needs migrating.
261
+
262
+ What the denormalised name costs: a rename does not travel backwards. Old
263
+ comments keep the name that was current when they were written, which is what
264
+ an audit trail should do, and the id is what lets you reconcile if you want
265
+ the current one.
266
+
267
+ With no `id` at all, two people sharing a display name are one author. If your
268
+ app has no accounts, mint the id yourself — you control the key, the lifetime
269
+ and the consent story, which HellDots cannot:
270
+
271
+ ```js
272
+ const KEY = "my-app-anon-id";
273
+ let id = localStorage.getItem(KEY);
274
+ if (!id) localStorage.setItem(KEY, (id = crypto.randomUUID()));
275
+ createCommentOverlay({ user: { name: typedName, id } });
276
+ ```
277
+
278
+ Bear in mind what that identifies: a browser profile, not a person.
279
+
280
+ Whatever you declare here is taken at face value and stored as-is. The record
281
+ says what your application asserted about who acted; verifying that claim is
282
+ your backend’s job, and `onChange` carries every mutation to it.
283
+
144
284
  ## Triage
145
285
 
146
286
  Comments carry an optional type, priority and free-form tags. All three start
@@ -151,6 +291,12 @@ neutral: the person reporting can classify, or not.
151
291
  | `type` | `bug`, `suggestion`, `question`, `improvement`, or `null` |
152
292
  | `priority` | `high`, `medium`, `low`, or `null` |
153
293
  | `tags` | any strings — trimmed, lowercased and de-duplicated |
294
+ | `status` | `open`, `in_progress`, `in_review`, `resolved` |
295
+
296
+ The status is the one field that is never neutral: every comment starts `open`
297
+ and moves through the lifecycle in any order. `open` is the only state painted
298
+ in an unsaturated off-white, so the three states somebody actually moved a
299
+ comment into are the ones that stand out.
154
300
 
155
301
  The inbox filters on all of them, combined with page and status. Resolved
156
302
  comments show how long they took, measured from creation to resolution.
@@ -165,6 +311,137 @@ overlay.setCommentStatus(id, "resolved"); // stamps the resolution time
165
311
  Passing `null` to `setCommentType` or `setCommentPriority` returns the field to
166
312
  its neutral state. Reopening a resolved comment clears its resolution time.
167
313
 
314
+ ### Reactions
315
+
316
+ Comments and replies take one of six reactions — 👍 👎 ❤️ 🎉 👀 🚀 — so a team
317
+ can agree, flag "watching this" or mark something shipped without adding a
318
+ reply. The set is fixed: a searchable picker would need an emoji dataset
319
+ larger than the whole widget.
320
+
321
+ The emoji button in a comment's action strip (or on a reply's meta line) is
322
+ where a reaction starts. Once there is one, a row of pills sits under the
323
+ comment — below its screenshot when it has one — and carries its own button for
324
+ adding another. Nothing is shown there until somebody reacts.
325
+
326
+ ```js
327
+ overlay.toggleCommentReaction(id, "👍");
328
+ overlay.toggleReplyReaction(commentId, replyId, "🎉");
329
+ ```
330
+
331
+ Both toggle: reacting again with the same emoji removes it. A reaction is
332
+ stored against `user.id` when you pass one, and against `user.name`
333
+ otherwise — so give HellDots an `id` if two people on your team can share a
334
+ display name:
335
+
336
+ ```js
337
+ createCommentOverlay({ user: { name: currentUser.name, id: currentUser.id } });
338
+ ```
339
+
340
+ Reactions ride along in `serializeComments()` output as `reactions`, an
341
+ `{ emoji: actorKey[] }` map that is `null` when nobody has reacted. The pills
342
+ show counts, never who reacted: the stored keys are your ids, and they stay
343
+ out of the UI.
344
+
345
+ ### Audit trail
346
+
347
+ Every comment carries an append-only log of what happened to it — who created
348
+ it, edited its text, moved its status or changed its classification, and when.
349
+ It shows up as a folded `History (n)` disclosure in the inbox detail, next to
350
+ the context block.
351
+
352
+ ```js
353
+ overlay.serializeComments()[0].history;
354
+ // [
355
+ // { type: "created", at: "…", actor: { id: "u_42", name: "Ana Pérez" } },
356
+ // { type: "status", at: "…", actor: {…}, from: "open", to: "resolved" },
357
+ // { type: "classified", at: "…", actor: {…}, field: "type", from: null, to: "bug" },
358
+ // ]
359
+ ```
360
+
361
+ Replies and reactions are deliberately **not** in it. A reply already carries
362
+ its own author and timestamp and is visible in the thread; reactions are
363
+ high-frequency signal with no audit value. That bound is what keeps the log at
364
+ three to five entries per comment — a hundred comments’ worth of history costs
365
+ about what two automatic screenshots cost.
366
+
367
+ Resolution time is derived from this log rather than stored beside it, so a
368
+ comment that was resolved, reopened and resolved again reports the duration of
369
+ the resolution currently in force, and the superseded ones are listed under
370
+ **Previous resolutions** in the same disclosure.
371
+
372
+ Two things worth knowing before you rely on it:
373
+
374
+ - **It is attributive, not evidential.** HellDots authenticates nobody. The log
375
+ records the `user` your app declared at the moment of the action, so it says
376
+ what your application asserted about who acted — not a verified fact. Verify
377
+ on your own backend if you need the stronger claim; `onChange` carries every
378
+ mutation to it.
379
+ - **Timestamps come from the acting client’s clock.** Merge corpora written on
380
+ machines whose clocks disagree and an entry can predate the comment it
381
+ belongs to. Durations are clamped at zero rather than rendered negative.
382
+
383
+ A corpus written before the log existed loads unchanged with `history: null`,
384
+ and its comments render no disclosure — additive, so nothing needs migrating.
385
+
386
+ ## Metrics and reports
387
+
388
+ The inbox header carries a **Metrics** button. It swaps the list for a
389
+ dashboard: totals, how many were resolved and how many came back, average and
390
+ median resolution time, bars per status, type and priority, and a daily
391
+ distribution. Each bar carries the colour its own picker uses, so a chip and
392
+ its bar read as the same thing.
393
+
394
+ The dashboard measures **what the panel is currently filtered to** — the
395
+ filter summary sits right above the figures, so they answer "what am I looking
396
+ at". For the unfiltered aggregate, ask the overlay:
397
+
398
+ ```js
399
+ overlay.getMetrics();
400
+ // {
401
+ // total: 42,
402
+ // byStatus: { open: 12, in_progress: 4, in_review: 2, resolved: 24 },
403
+ // byType: { bug: 18, suggestion: 9, question: 3, improvement: 4, unset: 8 },
404
+ // byPriority: { high: 7, medium: 15, low: 6, unset: 14 },
405
+ // overTime: [{ date: "2026-08-18", count: 5 }, …],
406
+ // resolution: { resolvedCount: 24, reopenedCount: 3,
407
+ // averageMs: 9000000, medianMs: 5400000 },
408
+ // }
409
+ ```
410
+
411
+ Every bucket is present even when empty, so you can index it without guarding.
412
+ `overTime` lists only the days that saw activity — filling the gaps would put
413
+ a year of empty buckets between two comments twelve months apart.
414
+
415
+ ### Exporting
416
+
417
+ Three buttons at the foot of the dashboard, and the same three as methods:
418
+
419
+ ```js
420
+ overlay.exportCommentsCsv(); // helldots-comments.csv — one row per comment
421
+ overlay.exportMetricsCsv(); // helldots-metrics.csv — section, key, value
422
+ overlay.printMetricsReport(); // the browser's print dialog → Save as PDF
423
+ ```
424
+
425
+ Both CSV methods **return the same text they download**, so a host that wanted
426
+ to send those rows somewhere instead of handing the user a file does not have
427
+ to build them a second time:
428
+
429
+ ```js
430
+ await api.post("/reports/comments", { csv: overlay.exportCommentsCsv() });
431
+ ```
432
+
433
+ The CSVs are RFC 4180 with a UTF-8 BOM, so Excel opens them without turning
434
+ every accent into mojibake, and a value that would otherwise be evaluated as a
435
+ formula is neutralised on the way out. Headers are the internal field names
436
+ rather than translated labels: the file is an interchange format, and a column
437
+ whose spelling follows the widget's locale cannot be joined against anything.
438
+ Screenshots stay out — a 33 KB base64 string in a spreadsheet cell is not data.
439
+
440
+ The PDF is the browser's. HellDots builds the report in its own document and
441
+ asks that document to print, so "Save as PDF" in the dialog gives you a real
442
+ one at no cost in bundle size — the lightest PDF library measured 133 KB gzip
443
+ against a 50 KB budget. What prints is the report, not the page behind it.
444
+
168
445
  ## Handing a comment to a coding agent
169
446
 
170
447
  Every comment has a **copy** button that puts a plain-text context block on the
@@ -192,19 +469,25 @@ OS: iOS 17.2
192
469
 
193
470
  ## Options
194
471
 
195
- | Option | Type | Default | |
196
- | ----------------------- | -------------------------------- | ------------------- | ----------------------------------------------------------------- |
197
- | `user` | `{ name: string }` | `"Anonymous"` | Author of new comments and replies |
198
- | `persistence` | `"localStorage"` \| `"none"` | `"none"` | Auto save/restore, or handle it yourself via callbacks |
199
- | `autoScreenshot` | `boolean` | `true` | Capture a screenshot and environment snapshot per comment |
200
- | `embedCrossOriginFonts` | `boolean` | `false` | Fetch unreadable stylesheets so their web fonts reach the capture |
201
- | `locale` | `string` | browser language | `"en"` and `"es"` ship; anything else falls back per key |
202
- | `linkParam` | `string` | `"helldotsComment"` | Query param used by "Copy link" URLs |
203
- | `navigate` | `(page: string) => void` | full page load | SPA router hook for the widget's cross-page jumps |
204
- | `autoDetectNavigation` | `boolean` | `false` | Run `notifyNavigation()` on popstate (back/forward) |
205
- | `shortcutKey` | `string` | `"c"` | Key that toggles comment mode |
206
- | `shortcutModifier` | `"alt"` \| `"ctrl"` \| `"shift"` | `"alt"` | Modifier for that key |
207
- | `autoInit` | `boolean` | `true` | When `false`, returns an initializer to call yourself |
472
+ | Option | Type | Default | |
473
+ | ----------------------- | ------------------------------------ | ------------------- | ----------------------------------------------------------------- |
474
+ | `user` | `{ name: string, id?: string }` | `"Anonymous"` | Author of new comments and replies; `id` persists as `authorId` |
475
+ | `persistence` | `"localStorage"` \| `"none"` | `"none"` | Auto save/restore, or handle it yourself via callbacks |
476
+ | `autoScreenshot` | `boolean` | `true` | Capture a screenshot and environment snapshot per comment |
477
+ | `embedCrossOriginFonts` | `boolean` | `false` | Fetch unreadable stylesheets so their web fonts reach the capture |
478
+ | `locale` | `string` | browser language | `"en"` and `"es"` ship; anything else falls back per key |
479
+ | `linkParam` | `string` | `"helldotsComment"` | Query param used by "Copy link" URLs |
480
+ | `navigate` | `(page: string) => void` | full page load | SPA router hook for the widget's cross-page jumps |
481
+ | `onReady` | `(overlay) => void` | | Widget mounted; the safe place to `loadComments()` |
482
+ | `onError` | `(error, context) => void` | | A survivable failure capture, storage, load, link or transform |
483
+ | `onCommentRequested` | `(id) => void \| Promise` | | A link points at a comment the widget does not hold |
484
+ | `transformScreenshot` | `(dataUrl, info) => Promise<string>` | | Swap every image the widget acquires for a string of your own |
485
+ | `onCommentModeChanged` | `(active: boolean) => void` | — | Comment mode turned on or off, however it was flipped |
486
+ | `onCommentOpened` | `(comment) => void` | — | Somebody opened a comment's thread — build unread counts on this |
487
+ | `autoDetectNavigation` | `boolean` | `false` | Run `notifyNavigation()` on popstate (back/forward) |
488
+ | `shortcutKey` | `string` | `"c"` | Key that toggles comment mode |
489
+ | `shortcutModifier` | `"alt"` \| `"ctrl"` \| `"shift"` | `"alt"` | Modifier for that key |
490
+ | `autoInit` | `boolean` | `true` | When `false`, returns an initializer to call yourself |
208
491
 
209
492
  ### Callbacks
210
493
 
@@ -217,6 +500,7 @@ createCommentOverlay({
217
500
  // "comment:created" | "comment:edited" | "comment:deleted"
218
501
  // "comment:status-changed" | "comment:updated" | "comment:anchor-lost"
219
502
  // "reply:added" | "reply:deleted" | "reply:edited"
503
+ // "reaction:toggled"
220
504
  api.post("/helldots-events", event);
221
505
  },
222
506
  });
@@ -227,17 +511,108 @@ TypeScript narrows the payload. The specific callbacks below carry the same
227
511
  events at the same moments — subscribe either way, or both. A handler that
228
512
  throws is caught and warned about, never rolling back the change.
229
513
 
230
- | Callback | Fires when |
231
- | --------------------------------- | -------------------------------------------------- |
232
- | `onCommentCreated(comment)` | A new comment is saved |
233
- | `onReplyAdded(comment, reply)` | A reply is added to any comment |
234
- | `onReplyDeleted(comment, reply)` | A reply is removed |
235
- | `onCommentEdited(comment)` | A comment's text is rewritten |
236
- | `onReplyEdited(comment, reply)` | A reply's text is rewritten |
237
- | `onCommentStatusChanged(comment)` | Status moves between open / in progress / resolved |
238
- | `onCommentUpdated(comment)` | Type, priority or tags change |
239
- | `onCommentDeleted(id)` | A comment is removed |
240
- | `onAnchorLost(comment)` | A comment could not be re-anchored on load |
514
+ Every one of them ends with a `meta` argument (the same fields are flattened
515
+ onto the `onChange` event), so an existing handler that ignores it keeps
516
+ working unchanged.
517
+
518
+ | Callback | Fires when |
519
+ | ----------------------------------------- | -------------------------------------------------------------- |
520
+ | `onCommentCreated(comment, meta)` | A new comment is saved |
521
+ | `onReplyAdded(comment, reply, meta)` | A reply is added to any comment |
522
+ | `onReplyDeleted(comment, reply, meta)` | A reply is removed |
523
+ | `onCommentEdited(comment, meta)` | A comment's text is rewritten |
524
+ | `onReplyEdited(comment, reply, meta)` | A reply's text is rewritten |
525
+ | `onCommentStatusChanged(comment, meta)` | Status moves along the lifecycle |
526
+ | `onCommentUpdated(comment, meta)` | Type, priority or tags change |
527
+ | `onCommentDeleted(id, meta)` | A comment is removed |
528
+ | `onAnchorLost(comment, meta)` | A comment could not be re-anchored |
529
+ | `onReactionToggled(comment, reply, meta)` | A reaction is added or removed (`reply` is `null` at the root) |
530
+
531
+ Five more do not report a change to a comment:
532
+
533
+ | Callback | Fires when |
534
+ | ------------------------------ | --------------------------------------------------------------- |
535
+ | `onReady(overlay)` | The widget has mounted and every method is safe to call |
536
+ | `onError(error, context)` | Something survivable went wrong — see below |
537
+ | `onCommentRequested(id)` | A link points at a comment the widget does not hold — see below |
538
+ | `onCommentModeChanged(active)` | Comment mode turned on or off — see below |
539
+ | `onCommentOpened(comment)` | Somebody opened a comment's thread — see below |
540
+
541
+ #### `meta.origin` — who caused the change
542
+
543
+ `"user"` is somebody acting inside the widget; `"host"` is your own code
544
+ calling a method. The inbox and the thread popover drive the very same public
545
+ methods you do, so this is the only thing that tells the two apart.
546
+
547
+ It matters as soon as more than one person is looking. Applying a change that
548
+ arrived over a socket means calling `setCommentStatus()` — which emits, which
549
+ sends it straight back to the server:
550
+
551
+ ```js
552
+ createCommentOverlay({
553
+ onChange: (event) => {
554
+ if (event.origin === "host") return; // our own write, echoed back
555
+ api.post("/helldots-events", event);
556
+ },
557
+ });
558
+
559
+ socket.on("comment:resolved", ({ id }) =>
560
+ overlay.setCommentStatus(id, "resolved")
561
+ );
562
+ ```
563
+
564
+ Without the guard that loop runs forever. `comment:anchor-lost` is always
565
+ `"host"` too, so this also silences the repeat every `notifyNavigation()`
566
+ produces for a comment whose element is not on the new page.
567
+
568
+ #### `meta.from` / `meta.to` — what moved
569
+
570
+ `comment:status-changed` carries both ends of the move, and `comment:updated`
571
+ adds `field` to say which of the three it was about:
572
+
573
+ ```js
574
+ onCommentStatusChanged: (comment, { from, to }) => {
575
+ if (from === "resolved") notify(`${comment.author} reopened this`);
576
+ },
577
+ onCommentUpdated: (comment, meta) => {
578
+ if (meta.field === "priority" && meta.to === "high") page(comment);
579
+ },
580
+ ```
581
+
582
+ `field` narrows `from` and `to` for you in TypeScript. Re-applying a value a
583
+ comment already holds is a no-op: no event, no write.
584
+
585
+ #### `onCommentModeChanged` and `onCommentOpened`
586
+
587
+ Comment mode is the one the host cannot observe on its own: the keyboard
588
+ shortcut never reaches your code, so an app that has to stand down while
589
+ somebody is picking an element has no other signal.
590
+
591
+ ```js
592
+ onCommentModeChanged: (active) => {
593
+ carousel.paused = active; // your drag-and-drop would fight the picker
594
+ },
595
+ ```
596
+
597
+ `onCommentOpened` fires when a thread is actually read — from its marker or
598
+ from the inbox detail, the only two places the replies are visible. It does
599
+ not fire when the inbox merely re-renders. HellDots stores no read state of
600
+ its own, because whose "read" it is depends on an identity only you can
601
+ persist:
602
+
603
+ ```js
604
+ onCommentOpened: (comment) => api.post(`/comments/${comment.id}/read`),
605
+ ```
606
+
607
+ #### `onError(error, context)`
608
+
609
+ Failures the widget survives but you would otherwise only find in the
610
+ console: `"capture"` (a screenshot did not render — the comment saves without
611
+ one), `"storage"` (localStorage could not be written, so this browser's copy
612
+ now diverges), `"load"` (a malformed record was skipped), `"link"` (an
613
+ `onCommentRequested` handler threw or rejected), `"transform"` (a
614
+ `transformScreenshot` handler failed, so the data URL was kept). The console
615
+ warning stays either way.
241
616
 
242
617
  ## API
243
618
 
@@ -261,9 +636,24 @@ overlay.setCommentStatus(id, status); // → boolean
261
636
  overlay.setCommentType(id, type); // → boolean
262
637
  overlay.setCommentPriority(id, priority); // → boolean
263
638
  overlay.setCommentTags(id, tags); // → boolean
639
+ overlay.toggleCommentReaction(id, emoji); // → boolean
640
+ overlay.toggleReplyReaction(commentId, replyId, emoji); // → boolean
641
+ overlay.setUser(user); // → boolean (null returns to the anonymous author)
642
+ overlay.exportCommentsCsv(comments?); // → string (and downloads it)
643
+ overlay.exportMetricsCsv(comments?); // → string (and downloads it)
264
644
  overlay.cleanup(); // remove the widget entirely
265
645
  ```
266
646
 
647
+ Two module-level helpers come with the package, for reading a deep link
648
+ before an overlay exists:
649
+
650
+ ```ts
651
+ import { readCommentLinkParam, DEFAULT_LINK_PARAM } from "helldots";
652
+
653
+ DEFAULT_LINK_PARAM; // "helldotsComment"
654
+ readCommentLinkParam(param?, href?); // → string | null
655
+ ```
656
+
267
657
  The setters return `false` for an unknown id or an invalid value, and make no
268
658
  change when they do. To reconcile against a backend after remote deletions,
269
659
  call `clearComments()` and then `loadComments(freshData)` — `loadComments`