helldots 0.12.0 → 0.13.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
@@ -1,12 +1,36 @@
1
+ <img src="https://raw.githubusercontent.com/xKeCo/helldots/main/assets/brand/helldots-mark.png" alt="" width="72" height="72">
2
+
1
3
  # HellDots
2
4
 
3
5
  Drop-in comment overlay for web apps. Your team clicks anywhere on a page,
4
6
  leaves a comment anchored to that element, and HellDots captures the context
5
7
  needed to act on it — a screenshot, the browser, the viewport, the DOM path.
6
8
 
7
- Comments survive reloads and re-anchor themselves after the page changes.
8
- Nothing is sent anywhere: you own the data through callbacks, or let the
9
- widget persist to `localStorage`.
9
+ [![npm](https://img.shields.io/npm/v/helldots)](https://www.npmjs.com/package/helldots)
10
+ [![bundle size](https://img.shields.io/bundlejs/size/helldots)](https://bundlejs.com/?q=helldots)
11
+ [![license](https://img.shields.io/npm/l/helldots)](https://github.com/xKeCo/helldots/blob/main/LICENSE)
12
+
13
+ **[Documentation](https://helldots.kcollazos.me/docs)** ·
14
+ **[Playground](https://helldots.kcollazos.me/docs/playground)** ·
15
+ **[npm](https://www.npmjs.com/package/helldots)**
16
+
17
+ ![Leaving a comment on a button, classifying it as a high-priority bug, opening its captured context, then the inbox and the metrics dashboard](https://raw.githubusercontent.com/xKeCo/helldots/main/assets/demo.gif)
18
+
19
+ ## Features
20
+
21
+ - **Anchored comments** that survive reloads and re-anchor after the page
22
+ changes — or are marked orphaned, never silently dropped
23
+ - **Automatic context**: a screenshot, region crops, URL, viewport, browser,
24
+ OS and the element's DOM path
25
+ - **Triage**: status, type, priority, tags, reactions and an audit trail
26
+ - **Metrics dashboard** with CSV and PDF export
27
+ - **Copy for agent**: a plain-text context block built for AI coding assistants
28
+ - **You own the data**: nothing is sent anywhere — wire the callbacks to your
29
+ backend, or let the widget persist to `localStorage`
30
+ - **Isolated**: renders inside a Shadow DOM, framework-agnostic, typed
31
+ - **English and Spanish** built in
32
+
33
+ ## Install
10
34
 
11
35
  ```bash
12
36
  npm install helldots
@@ -31,567 +55,58 @@ That's it. A toolbar appears at the bottom of the page. `Alt`+`C` (or
31
55
  `Option`+`C` on macOS) toggles comment mode; click anywhere to leave a
32
56
  comment, or drag to select a region and attach a screenshot of it.
33
57
 
34
- ### Wiring it to your own backend
58
+ ## Wiring it to your own backend
35
59
 
36
- Skip `persistence` and use the callbacks instead:
60
+ Skip `persistence` and use the callbacks instead. `onChange` carries every
61
+ mutation as one stream; `onReady` is the safe place to restore:
37
62
 
38
63
  ```js
39
64
  import { createCommentOverlay } from "helldots";
40
65
 
41
- const overlay = createCommentOverlay({
42
- user: { name: currentUser.name },
43
- onCommentCreated: (comment) => api.post("/comments", comment),
44
- onReplyAdded: (comment, reply) =>
45
- api.post(`/comments/${comment.id}/replies`, reply),
46
- onCommentStatusChanged: (comment) =>
47
- api.patch(`/comments/${comment.id}`, comment),
48
- onCommentUpdated: (comment) => api.patch(`/comments/${comment.id}`, comment),
49
- onCommentDeleted: (id) => api.delete(`/comments/${id}`),
50
- });
51
-
52
- // Restore on load
53
- const stored = await api.get("/comments");
54
- overlay.loadComments(stored);
55
- ```
56
-
57
- Every comment is plain JSON — pass `serializeComments()` output straight to
58
- your API and hand it back to `loadComments()` later.
59
-
60
- `loadComments()` is safe to call at any point: made before the widget has
61
- mounted, the data is held and applied at mount — though the counts come back
62
- as zeroes, because nothing has been resolved against the DOM yet. Use
63
- `onReady` when you want them:
64
-
65
- ```js
66
66
  createCommentOverlay({
67
+ user: { name: currentUser.name, id: currentUser.id },
68
+ onChange: (event) => api.post("/helldots-events", event),
67
69
  onReady: async (overlay) => {
68
- const { orphaned } = overlay.loadComments(await api.get("/comments"));
69
- if (orphaned) console.info(`${orphaned} comments lost their element`);
70
+ overlay.loadComments(await api.get("/comments"));
70
71
  },
71
72
  });
72
73
  ```
73
74
 
74
- ### Loading only the comment in a link
75
+ Every comment is plain JSON: pass `serializeComments()` output straight to
76
+ your API and hand it back to `loadComments()` later. Prefer one callback per
77
+ event (`onCommentCreated`, `onReplyAdded`, …)? They fire at the same moments —
78
+ see [Events](https://helldots.kcollazos.me/docs/api/events).
75
79
 
76
- A corpus too big to ship on every page load can be fetched per page — but
77
- then a shared "Copy link" URL points at a comment that is not in the set.
78
- `onCommentRequested` fires for exactly that case, once per id:
80
+ `localStorage` holds roughly a hundred comments with screenshots and assumes
81
+ one active tab per page; past that, persist through the callbacks. To keep
82
+ base64 images out of your database, upload them with `transformScreenshot`.
83
+ → [Persistence guide](https://helldots.kcollazos.me/docs/guides/persistence)
79
84
 
80
- ```js
81
- const overlay = createCommentOverlay({
82
- onCommentRequested: async (id) => {
83
- overlay.loadComments([await api.get(`/comments/${id}`)]);
84
- },
85
- });
86
-
87
- overlay.loadComments(await api.get(`/comments?page=${location.pathname}`));
88
- ```
85
+ ## Frameworks
89
86
 
90
- Return a promise and the link is retried once it settles — the inbox opens on
91
- the comment as soon as it lands. Until then it says the comment was not
92
- found, rather than doing nothing.
93
-
94
- To read the id yourself before any of this exists — to fetch that one comment
95
- and nothing else — the package exports the reader the widget uses, so the
96
- parameter name never has to be written twice:
97
-
98
- ```js
99
- import { readCommentLinkParam, DEFAULT_LINK_PARAM } from "helldots";
100
-
101
- const id = readCommentLinkParam(); // defaults to DEFAULT_LINK_PARAM
102
- const only = id ? [await api.get(`/comments/${id}`)] : [];
103
- ```
104
-
105
- ### Server-rendered apps
106
-
107
- Importing the package on the server is safe — nothing touches the DOM at
108
- import time. Just call `createCommentOverlay` from the client only:
87
+ **Server-rendered apps** (Next.js, Remix, Astro…): importing is safe — nothing
88
+ touches the DOM at import time. Create the overlay on the client only:
109
89
 
110
90
  ```jsx
111
- // Next.js, Remix, Astro…
112
- import { useEffect } from "react";
113
- import { createCommentOverlay } from "helldots";
114
-
115
- export function Comments({ user }) {
116
- useEffect(() => {
117
- const overlay = createCommentOverlay({ user, persistence: "localStorage" });
118
- return () => overlay.cleanup();
119
- }, [user]);
120
-
121
- return null;
122
- }
91
+ useEffect(() => {
92
+ const overlay = createCommentOverlay({ user, persistence: "localStorage" });
93
+ return () => overlay.cleanup();
94
+ }, [user]);
123
95
  ```
124
96
 
125
- ### Single-page apps
126
-
127
- A client-side router swaps the DOM without a page load, so tell the widget
128
- when a navigation happened and let its own cross-page jumps use your router:
97
+ **Single-page apps**: tell the widget when a navigation happened, and let its
98
+ own cross-page jumps use your router:
129
99
 
130
100
  ```js
131
101
  const overlay = createCommentOverlay({
132
102
  user,
133
- persistence: "localStorage",
134
- navigate: (page) => router.push(page), // "view on its page" without a reload
103
+ navigate: (page) => router.push(page),
135
104
  });
136
105
 
137
- // After every route render (React Router, Vue Router, …)
138
106
  router.afterEach(() => overlay.notifyNavigation());
139
107
  ```
140
108
 
141
- `notifyNavigation()` reclassifies every comment against the new URL,
142
- re-resolves anchors against the new DOM and rebuilds the markers. Calling it
143
- after a same-path re-render is also the way to re-anchor when your app
144
- replaced the route's DOM. `autoDetectNavigation: true` additionally covers
145
- back/forward (popstate) automatically.
146
-
147
- ## What gets captured
148
-
149
- When someone leaves a comment, HellDots records more than the text:
150
-
151
- **A screenshot of the page as they saw it.** Taken automatically, JPEG at half
152
- scale (~30–100 KB). The widget's own UI is excluded from the capture, so the
153
- toolbar never ends up inside the image. Dragging a region additionally attaches
154
- a full-resolution PNG crop of exactly what was selected.
155
-
156
- **The environment it was reported from** — URL, viewport size, screen
157
- resolution, device pixel ratio, browser, OS and language. A bug reported at
158
- 390×844 on iOS Safari says so, without anyone having to ask.
159
-
160
- **Where on the page it was.** A CSS selector, a DOM path, and a structural
161
- fingerprint of the element. If the page later changes and the element moves,
162
- the comment re-anchors to it. If it disappears entirely, the comment is marked
163
- orphaned rather than silently dropped.
164
-
165
- Set `autoScreenshot: false` to skip the capture — the render costs a moment
166
- on every comment, and some apps would rather not pay it.
167
-
168
- ### Web fonts in screenshots
169
-
170
- A screenshot is not a screen grab: the browser exposes no way to rasterize
171
- the painted page from JavaScript, so the capture is a re-render of the DOM.
172
- Anything the re-render cannot reach is missing from it — web fonts included.
173
-
174
- A font loaded through a cross-origin `<link>` (Google Fonts and friends) is
175
- one of those. Reading `cssRules` on such a stylesheet throws `SecurityError`,
176
- so its `@font-face` never reaches the capture and the text comes out in a
177
- fallback face. That is not only cosmetic: the fallback's metrics differ, so
178
- glyphs sit at different positions than on screen, and a drag selection tight
179
- around a few letters can come back holding the wrong ones.
180
-
181
- Three ways out, cheapest first:
182
-
183
- - **Self-host the font**, or add `crossorigin` to the `<link>`. The
184
- stylesheet becomes readable and the capture matches the page, with no
185
- extra requests at capture time.
186
- - **`embedCrossOriginFonts: true`.** HellDots re-fetches those stylesheets
187
- (the same URLs the page already loaded, cached per session) and hands them
188
- to the renderer. Off by default: a comment widget making third-party
189
- requests on your users' behalf should be your call, not ours.
190
- - **Leave it.** Captures of such a page stay misaligned where text is
191
- concerned; everything else about them is correct.
192
-
193
- ### When a capture is slow
194
-
195
- A screenshot is not a screenshot. The browser does not hand that to
196
- JavaScript, so the renderer re-creates the page instead: it clones the DOM,
197
- reads every element's computed style, inlines all of it, serialises the
198
- result into an SVG `<foreignObject>` and rasterises that.
199
-
200
- Reading the styles is the render. A browser exposes around 527 computed
201
- properties per element, and the renderer reads all of them — on a page with
202
- a few thousand live nodes that is hundreds of thousands of property reads,
203
- and it is ~91% of the cost. Two things follow, in this order:
204
-
205
- - **Nothing waits for it.** Clicking or dragging places the marker and opens
206
- the comment box immediately; the render runs behind it and the images drop
207
- in when they land. A dragged region shows a "Capturing…" slot in the
208
- attachment strip until its crop arrives, and Send waits for it if you get
209
- there first. This is always on; there is nothing to configure.
210
- - **The page no longer freezes while it happens.** The capture hands the
211
- main thread back to the browser every 8 ms, so the page keeps painting and
212
- accepting keystrokes even on a render that runs for a second. Also always
213
- on — and it is what makes the point above worth anything, since a box you
214
- can see but cannot type into is not much better than no box.
215
- - **`fastCapture: true`** narrows those reads to a curated list of the
216
- properties that change a pixel — measured at ~2.7x off that phase on a
217
- 12 000-element page, and pixel-identical to a full capture on the pages it
218
- was verified against.
219
-
220
- `fastCapture` is off by default because the list is a fidelity contract, and
221
- no list is provably complete for a page this library has never seen: a
222
- property it does not name is simply absent from the image. Turn it on for a
223
- heavy page, look at one capture before trusting it, and open an issue if
224
- something comes out wrong — the fix is one more entry in the list.
225
-
226
- There is a third lever if your page embeds same-origin iframes.
227
- **`skipIframeContent: true`** renders them blank instead of cloning what is
228
- inside. An iframe's cost is invisible from the outside — the renderer walks
229
- into the frame and clones its whole document, so a page that reports 242
230
- elements can be a capture of 9 245. Measured at 2374 ms against 82 ms on one
231
- 9 000-node embedded frame.
232
-
233
- The `<iframe>` element itself is kept: its box, its border, the space it
234
- occupies. That matters more than it sounds — removing the element instead
235
- would slide everything below it up by the frame's height while the crop is
236
- still taken at live page coordinates, which puts the bottom of every capture
237
- out of register.
238
-
239
- A cross-origin frame has nothing to gain here. The renderer cannot read into
240
- it, so it is already blank in the output, and contrary to a common guess it
241
- does not stall or wait on one either.
242
-
243
- What none of them touches is rasterisation, which is a single browser
244
- operation with no JavaScript inside it. On a very long page that stays a
245
- few hundred milliseconds of unavoidable work.
246
-
247
- ### When one dead asset holds a capture up
248
-
249
- To inline the page's images and fonts the renderer re-fetches them, and gives
250
- each one 30 seconds before giving up. A URL that never answers stalls the
251
- capture until that fires. The capture still succeeds — that asset becomes a
252
- transparent placeholder — but it waits first.
253
-
254
- The wait is bounded rather than multiplied: one dead asset and ten cost the
255
- same, because they are waited on concurrently. What it is not is one times the
256
- timeout. The setting drives two waits in sequence on the same asset — first
257
- for the image already on the page to finish loading, then for the fetch that
258
- inlines it — so the real cost is a consistent ~2x. The 30 second default is
259
- therefore about a minute.
260
-
261
- **`captureTimeout: 5000`** cuts that to roughly ten seconds. It is left at
262
- the default because lowering it trades a slow capture for a silently
263
- incomplete one: an asset that was only slow, rather than dead, gets dropped
264
- and leaves a hole with nothing to say so. Since these are assets your page
265
- has already loaded, most come from cache instantly and the tail is exactly
266
- the large or uncached ones you would be wrong to drop. Set it if you have
267
- measured your own page and decided which way you would rather it failed.
268
-
269
- ### Very long pages
270
-
271
- Browsers cap how large a canvas can be — 65 535 pixels in a dimension in
272
- Chromium, less in Firefox, and a separate and much lower area cap on mobile
273
- Safari. A page past that cap cannot be rendered at full scale, so HellDots
274
- fits the scale to what the browser will actually paint and checks that the
275
- result holds pixels before using it.
276
-
277
- Nothing below the cap changes. Past it the capture goes soft in proportion:
278
- a 68 000px page renders at 0.96, a 140 000px page at 0.47. If even the
279
- smallest attempt comes back empty, the capture fails through `onError`
280
- rather than attaching a blank image.
281
-
282
- ### Keeping screenshots out of your database
283
-
284
- Every image is stored as a base64 data URL inside the record — around 33 KB
285
- for the automatic capture alone. That is the first thing shed when
286
- localStorage hits its quota, and in your own backend it means a 33 KB string
287
- per comment in whatever column holds the JSON.
288
-
289
- `transformScreenshot` is where you swap it for a URL:
290
-
291
- ```js
292
- createCommentOverlay({
293
- transformScreenshot: async (dataUrl, { kind, commentId }) => {
294
- const blob = await (await fetch(dataUrl)).blob();
295
- const { url } = await api.upload(blob, { kind, commentId });
296
- return url; // stored in place of the data URL
297
- },
298
- });
299
- ```
300
-
301
- It runs for every image the widget acquires: the automatic capture
302
- (`kind: "context"`), a drag-crop region, and anything attached through the
303
- file picker on a comment or a reply (`kind: "attachment"`). The two kinds
304
- exist so the disposable one and the deliberate one can go to different
305
- buckets.
306
-
307
- **It runs at two different moments, and `kind` does not tell them apart.**
308
- Everything on a comment transforms when the comment is saved; an attachment
309
- on a _reply_ transforms when the file is picked, because `addReply()` is
310
- synchronous and cannot wait on your upload. So a reply attachment can be
311
- uploaded and then never referenced — the user closes the popover without
312
- sending — and a comment can do the same in a narrower window, by being
313
- dismissed while its upload is still in flight. Sweep for unreferenced blobs;
314
- a URL you were handed is not a promise that a record will point at it.
315
-
316
- It is **fail-open**. If your upload rejects — or resolves to anything that is
317
- not a non-empty string — the original data URL is kept and you get
318
- `onError(error, "transform")`. You receive a large record rather than losing
319
- somebody's comment.
320
-
321
- Not called for records you pass to `loadComments()`, nor for screenshots you
322
- hand to `addReply()` yourself: in both cases the strings are already yours.
323
-
324
- ## Identity
325
-
326
- HellDots authenticates nobody. It takes whoever your app says is signed in
327
- and records that:
328
-
329
- ```js
330
- createCommentOverlay({
331
- user: { name: currentUser.fullName, id: currentUser.id },
332
- });
333
- ```
334
-
335
- `name` is the display name — it is what appears on every comment and reply.
336
- `id` is optional, never rendered, and persisted as `authorId` on everything
337
- that user creates. Pass it whenever two people on your team can share a
338
- display name: without it they are indistinguishable in the record, and they
339
- share one reaction.
340
-
341
- ```js
342
- overlay.serializeComments()[0];
343
- // { author: "Ana Pérez", authorId: "u_42", ... }
344
- ```
345
-
346
- Both fields ride along in `serializeComments()` output, on comments and on
347
- replies alike. **The display name travels with the record**, so a store that
348
- holds nothing but comments — a database of its own, with no users table —
349
- renders every author and every audit entry without a single lookup back into
350
- your app. The id is opaque to HellDots: point it at your user table, at a
351
- comments-only store, or at nothing. `authorId` is `null` when you pass no
352
- `id`, and on records written before it existed — the field is additive, so no
353
- stored corpus needs migrating.
354
-
355
- What the denormalised name costs: a rename does not travel backwards. Old
356
- comments keep the name that was current when they were written, which is what
357
- an audit trail should do, and the id is what lets you reconcile if you want
358
- the current one.
359
-
360
- With no `id` at all, two people sharing a display name are one author. If your
361
- app has no accounts, mint the id yourself — you control the key, the lifetime
362
- and the consent story, which HellDots cannot:
363
-
364
- ```js
365
- const KEY = "my-app-anon-id";
366
- let id = localStorage.getItem(KEY);
367
- if (!id) localStorage.setItem(KEY, (id = crypto.randomUUID()));
368
- createCommentOverlay({ user: { name: typedName, id } });
369
- ```
370
-
371
- Bear in mind what that identifies: a browser profile, not a person.
372
-
373
- Whatever you declare here is taken at face value and stored as-is. The record
374
- says what your application asserted about who acted; verifying that claim is
375
- your backend’s job, and `onChange` carries every mutation to it.
376
-
377
- ### Who can edit and delete
378
-
379
- By default you may edit and delete what carries your identity, and nothing
380
- else. Somebody else’s comment simply has no Edit or Delete in its ⋯ menu.
381
-
382
- The rule compares `authorId` against your `user.id`, falling back to the
383
- display name when neither side has an id — so **pass `user.id` if this
384
- matters to you**. Two teammates sharing a display name own each other’s
385
- comments, and that is not a bug the widget can fix without one.
386
-
387
- A host that declares no `user` at all is unaffected: every record is written
388
- by the same anonymous actor, so nothing is ever hidden. Single-user setups
389
- and the playground behave exactly as they did.
390
-
391
- Override it with `can` when the rule is not yours — moderators, an owner
392
- role, a read-only viewer:
393
-
394
- ```js
395
- createCommentOverlay({
396
- user: { name: session.name, id: session.userId },
397
- can: (action, target) => {
398
- if (session.role === "admin") return true;
399
- if (session.role === "viewer") return false;
400
- return target.authorId === session.userId;
401
- },
402
- });
403
- ```
404
-
405
- `action` is one of `"edit:comment"`, `"delete:comment"`, `"edit:reply"` or
406
- `"delete:reply"`. `target` carries `{ id, author, authorId }`, plus
407
- `commentId` on the two reply actions. Return **literal `true`** to allow:
408
- anything else denies, including the `undefined` of a branch that forgot to
409
- return, and a `can` that throws denies while warning to the console. A
410
- permission predicate is the wrong place to be generous.
411
-
412
- Only those four actions are asked about. Status, type, priority, tags,
413
- reactions and replying stay open to everyone — triage is shared work, and
414
- all of it is reversible.
415
-
416
- The same verdict is readable from outside, so a delete button in your own
417
- chrome can ask the one rule instead of keeping a copy of it in step:
418
-
419
- ```js
420
- overlay.can("delete:comment", { id, author, authorId }); // → boolean
421
- ```
422
-
423
- **Your own calls are never refused.** `can` gates the widget’s menus and the
424
- mutations that come from a click inside it; `overlay.deleteComment(id)` from
425
- your code always goes through, so a moderation flow your backend has already
426
- authorized is not blocked by a client-side rule it outranks.
427
-
428
- > **This is not authorization.** HellDots runs in the page, so anyone with a
429
- > console reaches the API directly no matter what `can` returns. What it
430
- > removes is the accidental path — the button that should never have been
431
- > offered. Real enforcement belongs on your server: check `authorId` against
432
- > the session when the `comment:deleted` or `comment:edited` event reaches
433
- > your backend.
434
-
435
- ## Triage
436
-
437
- Comments carry an optional type, priority and free-form tags. All three start
438
- neutral: the person reporting can classify, or not.
439
-
440
- | Field | Values |
441
- | ---------- | --------------------------------------------------------- |
442
- | `type` | `bug`, `suggestion`, `question`, `improvement`, or `null` |
443
- | `priority` | `high`, `medium`, `low`, or `null` |
444
- | `tags` | any strings — trimmed, lowercased and de-duplicated |
445
- | `status` | `open`, `in_progress`, `in_review`, `resolved` |
446
-
447
- The status is the one field that is never neutral: every comment starts `open`
448
- and moves through the lifecycle in any order. `open` is the only state painted
449
- in an unsaturated off-white, so the three states somebody actually moved a
450
- comment into are the ones that stand out.
451
-
452
- The inbox filters on all of them, combined with page and status. Resolved
453
- comments show how long they took, measured from creation to resolution.
454
-
455
- ```js
456
- overlay.setCommentType(id, "bug");
457
- overlay.setCommentPriority(id, "high");
458
- overlay.setCommentTags(id, ["checkout", "ios"]);
459
- overlay.setCommentStatus(id, "resolved"); // stamps the resolution time
460
- ```
461
-
462
- Passing `null` to `setCommentType` or `setCommentPriority` returns the field to
463
- its neutral state. Reopening a resolved comment clears its resolution time.
464
-
465
- ### Reactions
466
-
467
- Comments and replies take one of six reactions — 👍 👎 ❤️ 🎉 👀 🚀 — so a team
468
- can agree, flag "watching this" or mark something shipped without adding a
469
- reply. The set is fixed: a searchable picker would need an emoji dataset
470
- larger than the whole widget.
471
-
472
- The emoji button in a comment's action strip (or on a reply's meta line) is
473
- where a reaction starts. Once there is one, a row of pills sits under the
474
- comment — below its screenshot when it has one — and carries its own button for
475
- adding another. Nothing is shown there until somebody reacts.
476
-
477
- ```js
478
- overlay.toggleCommentReaction(id, "👍");
479
- overlay.toggleReplyReaction(commentId, replyId, "🎉");
480
- ```
481
-
482
- Both toggle: reacting again with the same emoji removes it. A reaction is
483
- stored against `user.id` when you pass one, and against `user.name`
484
- otherwise — so give HellDots an `id` if two people on your team can share a
485
- display name:
486
-
487
- ```js
488
- createCommentOverlay({ user: { name: currentUser.name, id: currentUser.id } });
489
- ```
490
-
491
- Reactions ride along in `serializeComments()` output as `reactions`, an
492
- `{ emoji: actorKey[] }` map that is `null` when nobody has reacted. The pills
493
- show counts, never who reacted: the stored keys are your ids, and they stay
494
- out of the UI.
495
-
496
- ### Audit trail
497
-
498
- Every comment carries an append-only log of what happened to it — who created
499
- it, edited its text, moved its status or changed its classification, and when.
500
- It shows up as a folded `History (n)` disclosure in the inbox detail, next to
501
- the context block.
502
-
503
- ```js
504
- overlay.serializeComments()[0].history;
505
- // [
506
- // { type: "created", at: "…", actor: { id: "u_42", name: "Ana Pérez" } },
507
- // { type: "status", at: "…", actor: {…}, from: "open", to: "resolved" },
508
- // { type: "classified", at: "…", actor: {…}, field: "type", from: null, to: "bug" },
509
- // ]
510
- ```
511
-
512
- Replies and reactions are deliberately **not** in it. A reply already carries
513
- its own author and timestamp and is visible in the thread; reactions are
514
- high-frequency signal with no audit value. That bound is what keeps the log at
515
- three to five entries per comment — a hundred comments’ worth of history costs
516
- about what two automatic screenshots cost.
517
-
518
- Resolution time is derived from this log rather than stored beside it, so a
519
- comment that was resolved, reopened and resolved again reports the duration of
520
- the resolution currently in force, and the superseded ones are listed under
521
- **Previous resolutions** in the same disclosure.
522
-
523
- Two things worth knowing before you rely on it:
524
-
525
- - **It is attributive, not evidential.** HellDots authenticates nobody. The log
526
- records the `user` your app declared at the moment of the action, so it says
527
- what your application asserted about who acted — not a verified fact. Verify
528
- on your own backend if you need the stronger claim; `onChange` carries every
529
- mutation to it.
530
- - **Timestamps come from the acting client’s clock.** Merge corpora written on
531
- machines whose clocks disagree and an entry can predate the comment it
532
- belongs to. Durations are clamped at zero rather than rendered negative.
533
-
534
- A corpus written before the log existed loads unchanged with `history: null`,
535
- and its comments render no disclosure — additive, so nothing needs migrating.
536
-
537
- ## Metrics and reports
538
-
539
- The inbox header carries a **Metrics** button. It swaps the list for a
540
- dashboard: totals, how many were resolved and how many came back, average and
541
- median resolution time, bars per status, type and priority, and a daily
542
- distribution. Each bar carries the colour its own picker uses, so a chip and
543
- its bar read as the same thing.
544
-
545
- The dashboard measures **what the panel is currently filtered to** — the
546
- filter summary sits right above the figures, so they answer "what am I looking
547
- at". For the unfiltered aggregate, ask the overlay:
548
-
549
- ```js
550
- overlay.getMetrics();
551
- // {
552
- // total: 42,
553
- // byStatus: { open: 12, in_progress: 4, in_review: 2, resolved: 24 },
554
- // byType: { bug: 18, suggestion: 9, question: 3, improvement: 4, unset: 8 },
555
- // byPriority: { high: 7, medium: 15, low: 6, unset: 14 },
556
- // overTime: [{ date: "2026-08-18", count: 5 }, …],
557
- // resolution: { resolvedCount: 24, reopenedCount: 3,
558
- // averageMs: 9000000, medianMs: 5400000 },
559
- // }
560
- ```
561
-
562
- Every bucket is present even when empty, so you can index it without guarding.
563
- `overTime` lists only the days that saw activity — filling the gaps would put
564
- a year of empty buckets between two comments twelve months apart.
565
-
566
- ### Exporting
567
-
568
- Three buttons at the foot of the dashboard, and the same three as methods:
569
-
570
- ```js
571
- overlay.exportCommentsCsv(); // helldots-comments.csv — one row per comment
572
- overlay.exportMetricsCsv(); // helldots-metrics.csv — section, key, value
573
- overlay.printMetricsReport(); // the browser's print dialog → Save as PDF
574
- ```
575
-
576
- Both CSV methods **return the same text they download**, so a host that wanted
577
- to send those rows somewhere instead of handing the user a file does not have
578
- to build them a second time:
579
-
580
- ```js
581
- await api.post("/reports/comments", { csv: overlay.exportCommentsCsv() });
582
- ```
583
-
584
- The CSVs are RFC 4180 with a UTF-8 BOM, so Excel opens them without turning
585
- every accent into mojibake, and a value that would otherwise be evaluated as a
586
- formula is neutralised on the way out. Headers are the internal field names
587
- rather than translated labels: the file is an interchange format, and a column
588
- whose spelling follows the widget's locale cannot be joined against anything.
589
- Screenshots stay out — a 33 KB base64 string in a spreadsheet cell is not data.
590
-
591
- The PDF is the browser's. HellDots builds the report in its own document and
592
- asks that document to print, so "Save as PDF" in the dialog gives you a real
593
- one at no cost in bundle size — the lightest PDF library measured 133 KB gzip
594
- against a 50 KB budget. What prints is the report, not the page behind it.
109
+ → [Frameworks guide](https://helldots.kcollazos.me/docs/guides/frameworks)
595
110
 
596
111
  ## Handing a comment to a coding agent
597
112
 
@@ -618,222 +133,43 @@ Browser: Safari 17.2
618
133
  OS: iOS 17.2
619
134
  ```
620
135
 
621
- ## Options
622
-
623
- | Option | Type | Default | |
624
- | ----------------------- | ------------------------------------ | ------------------- | ----------------------------------------------------------------- |
625
- | `user` | `{ name: string, id?: string }` | `"Anonymous"` | Author of new comments and replies; `id` persists as `authorId` |
626
- | `can` | `(action, target) => boolean` | own records only | Vetoes editing and deleting a comment or reply from the widget |
627
- | `persistence` | `"localStorage"` \| `"none"` | `"none"` | Auto save/restore, or handle it yourself via callbacks |
628
- | `autoScreenshot` | `boolean` | `true` | Capture a screenshot and environment snapshot per comment |
629
- | `embedCrossOriginFonts` | `boolean` | `false` | Fetch unreadable stylesheets so their web fonts reach the capture |
630
- | `fastCapture` | `boolean` | `false` | Read a curated style list instead of all ~527 computed properties |
631
- | `skipIframeContent` | `boolean` | `false` | Render embedded documents as blank instead of cloning them |
632
- | `captureTimeout` | `number` | `30000` | Milliseconds one remote asset may hold a capture up |
633
- | `locale` | `string` | browser language | `"en"` and `"es"` ship; anything else falls back per key |
634
- | `linkParam` | `string` | `"helldotsComment"` | Query param used by "Copy link" URLs |
635
- | `navigate` | `(page: string) => void` | full page load | SPA router hook for the widget's cross-page jumps |
636
- | `onReady` | `(overlay) => void` | — | Widget mounted; the safe place to `loadComments()` |
637
- | `onError` | `(error, context) => void` | — | A survivable failure — capture, storage, load, link or transform |
638
- | `onCommentRequested` | `(id) => void \| Promise` | — | A link points at a comment the widget does not hold |
639
- | `transformScreenshot` | `(dataUrl, info) => Promise<string>` | — | Swap every image the widget acquires for a string of your own |
640
- | `onCommentModeChanged` | `(active: boolean) => void` | — | Comment mode turned on or off, however it was flipped |
641
- | `onCommentOpened` | `(comment) => void` | — | Somebody opened a comment's thread — build unread counts on this |
642
- | `autoDetectNavigation` | `boolean` | `false` | Run `notifyNavigation()` on popstate (back/forward) |
643
- | `shortcutKey` | `string` | `"c"` | Key that toggles comment mode |
644
- | `shortcutModifier` | `"alt"` \| `"ctrl"` \| `"shift"` | `"alt"` | Modifier for that key |
645
- | `autoInit` | `boolean` | `true` | When `false`, returns an initializer to call yourself |
646
-
647
- ### Callbacks
648
-
649
- Every change is also available as one stream, which is usually what you want
650
- when the whole thing syncs to a single endpoint:
651
-
652
- ```js
653
- createCommentOverlay({
654
- onChange: (event) => {
655
- // "comment:created" | "comment:edited" | "comment:deleted"
656
- // "comment:status-changed" | "comment:updated" | "comment:anchor-lost"
657
- // "reply:added" | "reply:deleted" | "reply:edited"
658
- // "reaction:toggled"
659
- api.post("/helldots-events", event);
660
- },
661
- });
662
- ```
663
-
664
- `ChangeEvent` is a discriminated union: switch on `event.type` and
665
- TypeScript narrows the payload. The specific callbacks below carry the same
666
- events at the same moments — subscribe either way, or both. A handler that
667
- throws is caught and warned about, never rolling back the change.
668
-
669
- Every one of them ends with a `meta` argument (the same fields are flattened
670
- onto the `onChange` event), so an existing handler that ignores it keeps
671
- working unchanged.
672
-
673
- | Callback | Fires when |
674
- | ----------------------------------------- | -------------------------------------------------------------- |
675
- | `onCommentCreated(comment, meta)` | A new comment is saved |
676
- | `onReplyAdded(comment, reply, meta)` | A reply is added to any comment |
677
- | `onReplyDeleted(comment, reply, meta)` | A reply is removed |
678
- | `onCommentEdited(comment, meta)` | A comment's text is rewritten |
679
- | `onReplyEdited(comment, reply, meta)` | A reply's text is rewritten |
680
- | `onCommentStatusChanged(comment, meta)` | Status moves along the lifecycle |
681
- | `onCommentUpdated(comment, meta)` | Type, priority or tags change |
682
- | `onCommentDeleted(id, meta)` | A comment is removed |
683
- | `onAnchorLost(comment, meta)` | A comment could not be re-anchored |
684
- | `onReactionToggled(comment, reply, meta)` | A reaction is added or removed (`reply` is `null` at the root) |
685
-
686
- Five more do not report a change to a comment:
687
-
688
- | Callback | Fires when |
689
- | ------------------------------ | --------------------------------------------------------------- |
690
- | `onReady(overlay)` | The widget has mounted and every method is safe to call |
691
- | `onError(error, context)` | Something survivable went wrong — see below |
692
- | `onCommentRequested(id)` | A link points at a comment the widget does not hold — see below |
693
- | `onCommentModeChanged(active)` | Comment mode turned on or off — see below |
694
- | `onCommentOpened(comment)` | Somebody opened a comment's thread — see below |
695
-
696
- #### `meta.origin` — who caused the change
697
-
698
- `"user"` is somebody acting inside the widget; `"host"` is your own code
699
- calling a method. The inbox and the thread popover drive the very same public
700
- methods you do, so this is the only thing that tells the two apart.
701
-
702
- It matters as soon as more than one person is looking. Applying a change that
703
- arrived over a socket means calling `setCommentStatus()` — which emits, which
704
- sends it straight back to the server:
705
-
706
- ```js
707
- createCommentOverlay({
708
- onChange: (event) => {
709
- if (event.origin === "host") return; // our own write, echoed back
710
- api.post("/helldots-events", event);
711
- },
712
- });
713
-
714
- socket.on("comment:resolved", ({ id }) =>
715
- overlay.setCommentStatus(id, "resolved")
716
- );
717
- ```
718
-
719
- Without the guard that loop runs forever. `comment:anchor-lost` is always
720
- `"host"` too, so this also silences the repeat every `notifyNavigation()`
721
- produces for a comment whose element is not on the new page.
722
-
723
- #### `meta.from` / `meta.to` — what moved
724
-
725
- `comment:status-changed` carries both ends of the move, and `comment:updated`
726
- adds `field` to say which of the three it was about:
727
-
728
- ```js
729
- onCommentStatusChanged: (comment, { from, to }) => {
730
- if (from === "resolved") notify(`${comment.author} reopened this`);
731
- },
732
- onCommentUpdated: (comment, meta) => {
733
- if (meta.field === "priority" && meta.to === "high") page(comment);
734
- },
735
- ```
736
-
737
- `field` narrows `from` and `to` for you in TypeScript. Re-applying a value a
738
- comment already holds is a no-op: no event, no write.
739
-
740
- #### `onCommentModeChanged` and `onCommentOpened`
741
-
742
- Comment mode is the one the host cannot observe on its own: the keyboard
743
- shortcut never reaches your code, so an app that has to stand down while
744
- somebody is picking an element has no other signal.
745
-
746
- ```js
747
- onCommentModeChanged: (active) => {
748
- carousel.paused = active; // your drag-and-drop would fight the picker
749
- },
750
- ```
751
-
752
- `onCommentOpened` fires when a thread is actually read — from its marker or
753
- from the inbox detail, the only two places the replies are visible. It does
754
- not fire when the inbox merely re-renders. HellDots stores no read state of
755
- its own, because whose "read" it is depends on an identity only you can
756
- persist:
757
-
758
- ```js
759
- onCommentOpened: (comment) => api.post(`/comments/${comment.id}/read`),
760
- ```
761
-
762
- #### `onError(error, context)`
763
-
764
- Failures the widget survives but you would otherwise only find in the
765
- console: `"capture"` (a screenshot did not render — the comment saves without
766
- one), `"storage"` (localStorage could not be written, so this browser's copy
767
- now diverges), `"load"` (a malformed record was skipped), `"link"` (an
768
- `onCommentRequested` handler threw or rejected), `"transform"` (a
769
- `transformScreenshot` handler failed, so the data URL was kept). The console
770
- warning stays either way.
771
-
772
- ## API
773
-
774
- ```ts
775
- const overlay = createCommentOverlay(options);
776
-
777
- overlay.comments; // Comment[]
778
- overlay.commentMode; // boolean
779
- overlay.toggleCommentMode();
780
- overlay.addReply(commentOrId, text, screenshots?); // → CommentReply | null
781
- overlay.deleteReply(commentId, replyId); // → boolean
782
- overlay.editComment(id, text); // → boolean
783
- overlay.editReply(commentId, replyId, text); // → boolean
784
- overlay.commentLink(id); // → string | null (shareable URL)
785
- overlay.serializeComments(); // → SerializedComment[]
786
- overlay.loadComments(data); // → { anchored, orphaned, inactive }
787
- overlay.notifyNavigation(); // re-sync after a client-side navigation
788
- overlay.clearComments(); // bulk reset, fires no callbacks
789
- overlay.deleteComment(id); // → boolean
790
- overlay.setCommentStatus(id, status); // → boolean
791
- overlay.setCommentType(id, type); // → boolean
792
- overlay.setCommentPriority(id, priority); // → boolean
793
- overlay.setCommentTags(id, tags); // → boolean
794
- overlay.toggleCommentReaction(id, emoji); // → boolean
795
- overlay.toggleReplyReaction(commentId, replyId, emoji); // → boolean
796
- overlay.setUser(user); // → boolean (null returns to the anonymous author)
797
- overlay.can(action, target); // → boolean (may this actor edit/delete it?)
798
- overlay.exportCommentsCsv(comments?); // → string (and downloads it)
799
- overlay.exportMetricsCsv(comments?); // → string (and downloads it)
800
- overlay.cleanup(); // remove the widget entirely
801
- ```
802
-
803
- Two module-level helpers come with the package, for reading a deep link
804
- before an overlay exists:
805
-
806
- ```ts
807
- import { readCommentLinkParam, DEFAULT_LINK_PARAM } from "helldots";
808
-
809
- DEFAULT_LINK_PARAM; // "helldotsComment"
810
- readCommentLinkParam(param?, href?); // → string | null
811
- ```
812
-
813
- The setters return `false` for an unknown id or an invalid value, and make no
814
- change when they do. To reconcile against a backend after remote deletions,
815
- call `clearComments()` and then `loadComments(freshData)` — `loadComments`
816
- alone replaces by id but never removes.
817
-
818
- TypeScript definitions ship with the package — no `@types` install needed.
819
-
820
- ## Storage notes
821
-
822
- With `persistence: "localStorage"`, every comment (screenshot included) lives
823
- under a single key shared across all pages of your app. Browsers cap that at
824
- roughly 5 MB, which is on the order of a hundred comments with screenshots.
825
-
826
- The mode assumes one active tab per page: writes from another tab are
827
- preserved on the next sync, but two tabs editing the same comment
828
- concurrently resolve last-write-wins, and a comment deleted in one tab can
829
- reappear if another tab still holding it in memory saves afterwards. Hosts
830
- that need real multi-tab editing should persist through the callbacks
831
- instead.
832
-
833
- When the quota is reached, HellDots sheds the _automatic_ screenshots of the
834
- oldest comments and retries, so the comments themselves survive. Screenshots a
835
- user deliberately attached are never discarded. If you expect heavy use, wire
836
- `onCommentCreated` to your own backend instead.
136
+ ## Common options
137
+
138
+ | Option | Default | |
139
+ | --------------------- | ---------------- | ----------------------------------------------------------------- |
140
+ | `user` | `"Anonymous"` | `{ name, id? }` — pass `id` whenever two people can share a name |
141
+ | `persistence` | `"none"` | `"localStorage"`, or handle it yourself through the callbacks |
142
+ | `onChange` | — | One stream for every mutation |
143
+ | `onReady` | — | Widget mounted; the safe place to `loadComments()` |
144
+ | `autoScreenshot` | `true` | Capture a screenshot and environment snapshot per comment |
145
+ | `transformScreenshot` | — | Upload each image and store your URL instead of a base64 data URL |
146
+ | `can` | own records only | Who may edit and delete — a UI guard, not authorization |
147
+ | `navigate` | full page load | SPA router hook for the widget's cross-page jumps |
148
+ | `locale` | browser language | `"en"` and `"es"` ship |
149
+
150
+ → [All options](https://helldots.kcollazos.me/docs/api/options) ·
151
+ [Events](https://helldots.kcollazos.me/docs/api/events) ·
152
+ [Methods](https://helldots.kcollazos.me/docs/api/methods) ·
153
+ [Types](https://helldots.kcollazos.me/docs/api/types)
154
+
155
+ ## Guides
156
+
157
+ - [Captures](https://helldots.kcollazos.me/docs/guides/captures) — what is
158
+ recorded, web fonts, slow or very long pages, iframes, timeouts
159
+ - [Identity](https://helldots.kcollazos.me/docs/guides/identity) — `user.id`,
160
+ who can edit and delete, and what `can` does not secure
161
+ - [Triage](https://helldots.kcollazos.me/docs/guides/triage) — status, type,
162
+ priority, tags, reactions and the audit trail
163
+ - [Metrics](https://helldots.kcollazos.me/docs/guides/metrics) — the
164
+ dashboard, `getMetrics()`, CSV and PDF export
165
+ - [Persistence](https://helldots.kcollazos.me/docs/guides/persistence) —
166
+ `localStorage` limits, your own backend, screenshot uploads
167
+ - [Deep links](https://helldots.kcollazos.me/docs/guides/deep-links) —
168
+ shareable comment URLs, and loading only the linked comment
169
+ - [Realtime](https://helldots.kcollazos.me/docs/guides/realtime) — syncing
170
+ over a socket without echoing your own writes
171
+ - [Frameworks](https://helldots.kcollazos.me/docs/guides/frameworks) —
172
+ server rendering, SPA routers, hotkeys, no-bundler setups, deferred mounts
837
173
 
838
174
  ## Browser support
839
175
 
@@ -844,7 +180,8 @@ page's CSS cannot leak into it and its styles cannot leak out.
844
180
 
845
181
  This package ships ES modules only. `import` works everywhere — bundlers, Vite,
846
182
  Next.js, Node ≥ 18, native `<script type="module">`. There is no CommonJS
847
- build, so `require("helldots")` will not work.
183
+ build, so `require("helldots")` will not work. TypeScript definitions ship
184
+ with the package.
848
185
 
849
186
  For a plain `<script>` tag with no bundler, a self-contained UMD build is on
850
187
  the CDN: