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 +91 -754
- package/dist/helldots.esm.js +6 -6
- package/dist/helldots.esm.js.map +3 -3
- package/dist/helldots.umd.js +12 -12
- package/package.json +1 -1
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
[](https://www.npmjs.com/package/helldots)
|
|
10
|
+
[](https://bundlejs.com/?q=helldots)
|
|
11
|
+
[](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
|
+

|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
the
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
622
|
-
|
|
623
|
-
| Option
|
|
624
|
-
|
|
|
625
|
-
| `user`
|
|
626
|
-
| `
|
|
627
|
-
| `
|
|
628
|
-
| `
|
|
629
|
-
| `
|
|
630
|
-
| `
|
|
631
|
-
| `
|
|
632
|
-
| `
|
|
633
|
-
| `locale`
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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:
|