helldots 0.4.0 → 0.6.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 +296 -22
- package/dist/helldots.esm.js +6 -1451
- package/dist/helldots.esm.js.map +4 -4
- package/dist/helldots.umd.js +14 -1459
- package/dist/index.d.ts +312 -12
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -73,12 +73,34 @@ export function Comments({ user }) {
|
|
|
73
73
|
}
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
### Single-page apps
|
|
77
|
+
|
|
78
|
+
A client-side router swaps the DOM without a page load, so tell the widget
|
|
79
|
+
when a navigation happened and let its own cross-page jumps use your router:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
const overlay = createCommentOverlay({
|
|
83
|
+
user,
|
|
84
|
+
persistence: "localStorage",
|
|
85
|
+
navigate: (page) => router.push(page), // "view on its page" without a reload
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// After every route render (React Router, Vue Router, …)
|
|
89
|
+
router.afterEach(() => overlay.notifyNavigation());
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`notifyNavigation()` reclassifies every comment against the new URL,
|
|
93
|
+
re-resolves anchors against the new DOM and rebuilds the markers. Calling it
|
|
94
|
+
after a same-path re-render is also the way to re-anchor when your app
|
|
95
|
+
replaced the route's DOM. `autoDetectNavigation: true` additionally covers
|
|
96
|
+
back/forward (popstate) automatically.
|
|
97
|
+
|
|
76
98
|
## What gets captured
|
|
77
99
|
|
|
78
100
|
When someone leaves a comment, HellDots records more than the text:
|
|
79
101
|
|
|
80
102
|
**A screenshot of the page as they saw it.** Taken automatically, JPEG at half
|
|
81
|
-
scale (~30–100 KB). The widget's own UI is
|
|
103
|
+
scale (~30–100 KB). The widget's own UI is excluded from the capture, so the
|
|
82
104
|
toolbar never ends up inside the image. Dragging a region additionally attaches
|
|
83
105
|
a full-resolution PNG crop of exactly what was selected.
|
|
84
106
|
|
|
@@ -94,6 +116,84 @@ orphaned rather than silently dropped.
|
|
|
94
116
|
Set `autoScreenshot: false` to skip the capture — the render costs a moment
|
|
95
117
|
on every comment, and some apps would rather not pay it.
|
|
96
118
|
|
|
119
|
+
### Web fonts in screenshots
|
|
120
|
+
|
|
121
|
+
A screenshot is not a screen grab: the browser exposes no way to rasterize
|
|
122
|
+
the painted page from JavaScript, so the capture is a re-render of the DOM.
|
|
123
|
+
Anything the re-render cannot reach is missing from it — web fonts included.
|
|
124
|
+
|
|
125
|
+
A font loaded through a cross-origin `<link>` (Google Fonts and friends) is
|
|
126
|
+
one of those. Reading `cssRules` on such a stylesheet throws `SecurityError`,
|
|
127
|
+
so its `@font-face` never reaches the capture and the text comes out in a
|
|
128
|
+
fallback face. That is not only cosmetic: the fallback's metrics differ, so
|
|
129
|
+
glyphs sit at different positions than on screen, and a drag selection tight
|
|
130
|
+
around a few letters can come back holding the wrong ones.
|
|
131
|
+
|
|
132
|
+
Three ways out, cheapest first:
|
|
133
|
+
|
|
134
|
+
- **Self-host the font**, or add `crossorigin` to the `<link>`. The
|
|
135
|
+
stylesheet becomes readable and the capture matches the page, with no
|
|
136
|
+
extra requests at capture time.
|
|
137
|
+
- **`embedCrossOriginFonts: true`.** HellDots re-fetches those stylesheets
|
|
138
|
+
(the same URLs the page already loaded, cached per session) and hands them
|
|
139
|
+
to the renderer. Off by default: a comment widget making third-party
|
|
140
|
+
requests on your users' behalf should be your call, not ours.
|
|
141
|
+
- **Leave it.** Captures of such a page stay misaligned where text is
|
|
142
|
+
concerned; everything else about them is correct.
|
|
143
|
+
|
|
144
|
+
## Identity
|
|
145
|
+
|
|
146
|
+
HellDots authenticates nobody. It takes whoever your app says is signed in
|
|
147
|
+
and records that:
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
createCommentOverlay({
|
|
151
|
+
user: { name: currentUser.fullName, id: currentUser.id },
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`name` is the display name — it is what appears on every comment and reply.
|
|
156
|
+
`id` is optional, never rendered, and persisted as `authorId` on everything
|
|
157
|
+
that user creates. Pass it whenever two people on your team can share a
|
|
158
|
+
display name: without it they are indistinguishable in the record, and they
|
|
159
|
+
share one reaction.
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
overlay.serializeComments()[0];
|
|
163
|
+
// { author: "Ana Pérez", authorId: "u_42", ... }
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Both fields ride along in `serializeComments()` output, on comments and on
|
|
167
|
+
replies alike. **The display name travels with the record**, so a store that
|
|
168
|
+
holds nothing but comments — a database of its own, with no users table —
|
|
169
|
+
renders every author and every audit entry without a single lookup back into
|
|
170
|
+
your app. The id is opaque to HellDots: point it at your user table, at a
|
|
171
|
+
comments-only store, or at nothing. `authorId` is `null` when you pass no
|
|
172
|
+
`id`, and on records written before it existed — the field is additive, so no
|
|
173
|
+
stored corpus needs migrating.
|
|
174
|
+
|
|
175
|
+
What the denormalised name costs: a rename does not travel backwards. Old
|
|
176
|
+
comments keep the name that was current when they were written, which is what
|
|
177
|
+
an audit trail should do, and the id is what lets you reconcile if you want
|
|
178
|
+
the current one.
|
|
179
|
+
|
|
180
|
+
With no `id` at all, two people sharing a display name are one author. If your
|
|
181
|
+
app has no accounts, mint the id yourself — you control the key, the lifetime
|
|
182
|
+
and the consent story, which HellDots cannot:
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
const KEY = "my-app-anon-id";
|
|
186
|
+
let id = localStorage.getItem(KEY);
|
|
187
|
+
if (!id) localStorage.setItem(KEY, (id = crypto.randomUUID()));
|
|
188
|
+
createCommentOverlay({ user: { name: typedName, id } });
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Bear in mind what that identifies: a browser profile, not a person.
|
|
192
|
+
|
|
193
|
+
Whatever you declare here is taken at face value and stored as-is. The record
|
|
194
|
+
says what your application asserted about who acted; verifying that claim is
|
|
195
|
+
your backend’s job, and `onChange` carries every mutation to it.
|
|
196
|
+
|
|
97
197
|
## Triage
|
|
98
198
|
|
|
99
199
|
Comments carry an optional type, priority and free-form tags. All three start
|
|
@@ -104,6 +204,12 @@ neutral: the person reporting can classify, or not.
|
|
|
104
204
|
| `type` | `bug`, `suggestion`, `question`, `improvement`, or `null` |
|
|
105
205
|
| `priority` | `high`, `medium`, `low`, or `null` |
|
|
106
206
|
| `tags` | any strings — trimmed, lowercased and de-duplicated |
|
|
207
|
+
| `status` | `open`, `in_progress`, `in_review`, `resolved` |
|
|
208
|
+
|
|
209
|
+
The status is the one field that is never neutral: every comment starts `open`
|
|
210
|
+
and moves through the lifecycle in any order. `open` is the only state painted
|
|
211
|
+
in an unsaturated off-white, so the three states somebody actually moved a
|
|
212
|
+
comment into are the ones that stand out.
|
|
107
213
|
|
|
108
214
|
The inbox filters on all of them, combined with page and status. Resolved
|
|
109
215
|
comments show how long they took, measured from creation to resolution.
|
|
@@ -118,6 +224,129 @@ overlay.setCommentStatus(id, "resolved"); // stamps the resolution time
|
|
|
118
224
|
Passing `null` to `setCommentType` or `setCommentPriority` returns the field to
|
|
119
225
|
its neutral state. Reopening a resolved comment clears its resolution time.
|
|
120
226
|
|
|
227
|
+
### Reactions
|
|
228
|
+
|
|
229
|
+
Comments and replies take one of six reactions — 👍 👎 ❤️ 🎉 👀 🚀 — so a team
|
|
230
|
+
can agree, flag "watching this" or mark something shipped without adding a
|
|
231
|
+
reply. The set is fixed: a searchable picker would need an emoji dataset
|
|
232
|
+
larger than the whole widget.
|
|
233
|
+
|
|
234
|
+
The emoji button in a comment's action strip (or on a reply's meta line) is
|
|
235
|
+
where a reaction starts. Once there is one, a row of pills sits under the
|
|
236
|
+
comment — below its screenshot when it has one — and carries its own button for
|
|
237
|
+
adding another. Nothing is shown there until somebody reacts.
|
|
238
|
+
|
|
239
|
+
```js
|
|
240
|
+
overlay.toggleCommentReaction(id, "👍");
|
|
241
|
+
overlay.toggleReplyReaction(commentId, replyId, "🎉");
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Both toggle: reacting again with the same emoji removes it. A reaction is
|
|
245
|
+
stored against `user.id` when you pass one, and against `user.name`
|
|
246
|
+
otherwise — so give HellDots an `id` if two people on your team can share a
|
|
247
|
+
display name:
|
|
248
|
+
|
|
249
|
+
```js
|
|
250
|
+
createCommentOverlay({ user: { name: currentUser.name, id: currentUser.id } });
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Reactions ride along in `serializeComments()` output as `reactions`, an
|
|
254
|
+
`{ emoji: actorKey[] }` map that is `null` when nobody has reacted. The pills
|
|
255
|
+
show counts, never who reacted: the stored keys are your ids, and they stay
|
|
256
|
+
out of the UI.
|
|
257
|
+
|
|
258
|
+
### Audit trail
|
|
259
|
+
|
|
260
|
+
Every comment carries an append-only log of what happened to it — who created
|
|
261
|
+
it, edited its text, moved its status or changed its classification, and when.
|
|
262
|
+
It shows up as a folded `History (n)` disclosure in the inbox detail, next to
|
|
263
|
+
the context block.
|
|
264
|
+
|
|
265
|
+
```js
|
|
266
|
+
overlay.serializeComments()[0].history;
|
|
267
|
+
// [
|
|
268
|
+
// { type: "created", at: "…", actor: { id: "u_42", name: "Ana Pérez" } },
|
|
269
|
+
// { type: "status", at: "…", actor: {…}, from: "open", to: "resolved" },
|
|
270
|
+
// { type: "classified", at: "…", actor: {…}, field: "type", from: null, to: "bug" },
|
|
271
|
+
// ]
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Replies and reactions are deliberately **not** in it. A reply already carries
|
|
275
|
+
its own author and timestamp and is visible in the thread; reactions are
|
|
276
|
+
high-frequency signal with no audit value. That bound is what keeps the log at
|
|
277
|
+
three to five entries per comment — a hundred comments’ worth of history costs
|
|
278
|
+
about what two automatic screenshots cost.
|
|
279
|
+
|
|
280
|
+
Resolution time is derived from this log rather than stored beside it, so a
|
|
281
|
+
comment that was resolved, reopened and resolved again reports the duration of
|
|
282
|
+
the resolution currently in force, and the superseded ones are listed under
|
|
283
|
+
**Previous resolutions** in the same disclosure.
|
|
284
|
+
|
|
285
|
+
Two things worth knowing before you rely on it:
|
|
286
|
+
|
|
287
|
+
- **It is attributive, not evidential.** HellDots authenticates nobody. The log
|
|
288
|
+
records the `user` your app declared at the moment of the action, so it says
|
|
289
|
+
what your application asserted about who acted — not a verified fact. Verify
|
|
290
|
+
on your own backend if you need the stronger claim; `onChange` carries every
|
|
291
|
+
mutation to it.
|
|
292
|
+
- **Timestamps come from the acting client’s clock.** Merge corpora written on
|
|
293
|
+
machines whose clocks disagree and an entry can predate the comment it
|
|
294
|
+
belongs to. Durations are clamped at zero rather than rendered negative.
|
|
295
|
+
|
|
296
|
+
A corpus written before the log existed loads unchanged with `history: null`,
|
|
297
|
+
and its comments render no disclosure — additive, so nothing needs migrating.
|
|
298
|
+
|
|
299
|
+
## Metrics and reports
|
|
300
|
+
|
|
301
|
+
The inbox header carries a **Metrics** button. It swaps the list for a
|
|
302
|
+
dashboard: totals, how many were resolved and how many came back, average and
|
|
303
|
+
median resolution time, bars per status, type and priority, and a daily
|
|
304
|
+
distribution. Each bar carries the colour its own picker uses, so a chip and
|
|
305
|
+
its bar read as the same thing.
|
|
306
|
+
|
|
307
|
+
The dashboard measures **what the panel is currently filtered to** — the
|
|
308
|
+
filter summary sits right above the figures, so they answer "what am I looking
|
|
309
|
+
at". For the unfiltered aggregate, ask the overlay:
|
|
310
|
+
|
|
311
|
+
```js
|
|
312
|
+
overlay.getMetrics();
|
|
313
|
+
// {
|
|
314
|
+
// total: 42,
|
|
315
|
+
// byStatus: { open: 12, in_progress: 4, in_review: 2, resolved: 24 },
|
|
316
|
+
// byType: { bug: 18, suggestion: 9, question: 3, improvement: 4, unset: 8 },
|
|
317
|
+
// byPriority: { high: 7, medium: 15, low: 6, unset: 14 },
|
|
318
|
+
// overTime: [{ date: "2026-08-18", count: 5 }, …],
|
|
319
|
+
// resolution: { resolvedCount: 24, reopenedCount: 3,
|
|
320
|
+
// averageMs: 9000000, medianMs: 5400000 },
|
|
321
|
+
// }
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Every bucket is present even when empty, so you can index it without guarding.
|
|
325
|
+
`overTime` lists only the days that saw activity — filling the gaps would put
|
|
326
|
+
a year of empty buckets between two comments twelve months apart.
|
|
327
|
+
|
|
328
|
+
### Exporting
|
|
329
|
+
|
|
330
|
+
Three buttons at the foot of the dashboard, and the same three as methods:
|
|
331
|
+
|
|
332
|
+
```js
|
|
333
|
+
overlay.exportCommentsCsv(); // helldots-comments.csv — one row per comment
|
|
334
|
+
overlay.exportMetricsCsv(); // helldots-metrics.csv — section, key, value
|
|
335
|
+
overlay.printMetricsReport(); // the browser's print dialog → Save as PDF
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
The CSVs are RFC 4180 with a UTF-8 BOM, so Excel opens them without turning
|
|
339
|
+
every accent into mojibake, and a value that would otherwise be evaluated as a
|
|
340
|
+
formula is neutralised on the way out. Headers are the internal field names
|
|
341
|
+
rather than translated labels: the file is an interchange format, and a column
|
|
342
|
+
whose spelling follows the widget's locale cannot be joined against anything.
|
|
343
|
+
Screenshots stay out — a 33 KB base64 string in a spreadsheet cell is not data.
|
|
344
|
+
|
|
345
|
+
The PDF is the browser's. HellDots builds the report in its own document and
|
|
346
|
+
asks that document to print, so "Save as PDF" in the dialog gives you a real
|
|
347
|
+
one at no cost in bundle size — the lightest PDF library measured 133 KB gzip
|
|
348
|
+
against a 50 KB budget. What prints is the report, not the page behind it.
|
|
349
|
+
|
|
121
350
|
## Handing a comment to a coding agent
|
|
122
351
|
|
|
123
352
|
Every comment has a **copy** button that puts a plain-text context block on the
|
|
@@ -145,26 +374,54 @@ OS: iOS 17.2
|
|
|
145
374
|
|
|
146
375
|
## Options
|
|
147
376
|
|
|
148
|
-
| Option
|
|
149
|
-
|
|
|
150
|
-
| `user`
|
|
151
|
-
| `persistence`
|
|
152
|
-
| `autoScreenshot`
|
|
153
|
-
| `
|
|
154
|
-
| `
|
|
155
|
-
| `
|
|
156
|
-
| `
|
|
377
|
+
| Option | Type | Default | |
|
|
378
|
+
| ----------------------- | -------------------------------- | ------------------- | ----------------------------------------------------------------- |
|
|
379
|
+
| `user` | `{ name: string, id?: string }` | `"Anonymous"` | Author of new comments and replies; `id` persists as `authorId` |
|
|
380
|
+
| `persistence` | `"localStorage"` \| `"none"` | `"none"` | Auto save/restore, or handle it yourself via callbacks |
|
|
381
|
+
| `autoScreenshot` | `boolean` | `true` | Capture a screenshot and environment snapshot per comment |
|
|
382
|
+
| `embedCrossOriginFonts` | `boolean` | `false` | Fetch unreadable stylesheets so their web fonts reach the capture |
|
|
383
|
+
| `locale` | `string` | browser language | `"en"` and `"es"` ship; anything else falls back per key |
|
|
384
|
+
| `linkParam` | `string` | `"helldotsComment"` | Query param used by "Copy link" URLs |
|
|
385
|
+
| `navigate` | `(page: string) => void` | full page load | SPA router hook for the widget's cross-page jumps |
|
|
386
|
+
| `autoDetectNavigation` | `boolean` | `false` | Run `notifyNavigation()` on popstate (back/forward) |
|
|
387
|
+
| `shortcutKey` | `string` | `"c"` | Key that toggles comment mode |
|
|
388
|
+
| `shortcutModifier` | `"alt"` \| `"ctrl"` \| `"shift"` | `"alt"` | Modifier for that key |
|
|
389
|
+
| `autoInit` | `boolean` | `true` | When `false`, returns an initializer to call yourself |
|
|
157
390
|
|
|
158
391
|
### Callbacks
|
|
159
392
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
393
|
+
Every change is also available as one stream, which is usually what you want
|
|
394
|
+
when the whole thing syncs to a single endpoint:
|
|
395
|
+
|
|
396
|
+
```js
|
|
397
|
+
createCommentOverlay({
|
|
398
|
+
onChange: (event) => {
|
|
399
|
+
// "comment:created" | "comment:edited" | "comment:deleted"
|
|
400
|
+
// "comment:status-changed" | "comment:updated" | "comment:anchor-lost"
|
|
401
|
+
// "reply:added" | "reply:deleted" | "reply:edited"
|
|
402
|
+
// "reaction:toggled"
|
|
403
|
+
api.post("/helldots-events", event);
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
`ChangeEvent` is a discriminated union: switch on `event.type` and
|
|
409
|
+
TypeScript narrows the payload. The specific callbacks below carry the same
|
|
410
|
+
events at the same moments — subscribe either way, or both. A handler that
|
|
411
|
+
throws is caught and warned about, never rolling back the change.
|
|
412
|
+
|
|
413
|
+
| Callback | Fires when |
|
|
414
|
+
| ----------------------------------- | -------------------------------------------------------------- |
|
|
415
|
+
| `onCommentCreated(comment)` | A new comment is saved |
|
|
416
|
+
| `onReplyAdded(comment, reply)` | A reply is added to any comment |
|
|
417
|
+
| `onReplyDeleted(comment, reply)` | A reply is removed |
|
|
418
|
+
| `onCommentEdited(comment)` | A comment's text is rewritten |
|
|
419
|
+
| `onReplyEdited(comment, reply)` | A reply's text is rewritten |
|
|
420
|
+
| `onCommentStatusChanged(comment)` | Status moves along the lifecycle |
|
|
421
|
+
| `onCommentUpdated(comment)` | Type, priority or tags change |
|
|
422
|
+
| `onCommentDeleted(id)` | A comment is removed |
|
|
423
|
+
| `onAnchorLost(comment)` | A comment could not be re-anchored on load |
|
|
424
|
+
| `onReactionToggled(comment, reply)` | A reaction is added or removed (`reply` is `null` at the root) |
|
|
168
425
|
|
|
169
426
|
## API
|
|
170
427
|
|
|
@@ -174,19 +431,29 @@ const overlay = createCommentOverlay(options);
|
|
|
174
431
|
overlay.comments; // Comment[]
|
|
175
432
|
overlay.commentMode; // boolean
|
|
176
433
|
overlay.toggleCommentMode();
|
|
177
|
-
overlay.addReply(
|
|
434
|
+
overlay.addReply(commentOrId, text, screenshots?); // → CommentReply | null
|
|
435
|
+
overlay.deleteReply(commentId, replyId); // → boolean
|
|
436
|
+
overlay.editComment(id, text); // → boolean
|
|
437
|
+
overlay.editReply(commentId, replyId, text); // → boolean
|
|
438
|
+
overlay.commentLink(id); // → string | null (shareable URL)
|
|
178
439
|
overlay.serializeComments(); // → SerializedComment[]
|
|
179
440
|
overlay.loadComments(data); // → { anchored, orphaned, inactive }
|
|
441
|
+
overlay.notifyNavigation(); // re-sync after a client-side navigation
|
|
442
|
+
overlay.clearComments(); // bulk reset, fires no callbacks
|
|
180
443
|
overlay.deleteComment(id); // → boolean
|
|
181
444
|
overlay.setCommentStatus(id, status); // → boolean
|
|
182
445
|
overlay.setCommentType(id, type); // → boolean
|
|
183
446
|
overlay.setCommentPriority(id, priority); // → boolean
|
|
184
447
|
overlay.setCommentTags(id, tags); // → boolean
|
|
448
|
+
overlay.toggleCommentReaction(id, emoji); // → boolean
|
|
449
|
+
overlay.toggleReplyReaction(commentId, replyId, emoji); // → boolean
|
|
185
450
|
overlay.cleanup(); // remove the widget entirely
|
|
186
451
|
```
|
|
187
452
|
|
|
188
453
|
The setters return `false` for an unknown id or an invalid value, and make no
|
|
189
|
-
change when they do.
|
|
454
|
+
change when they do. To reconcile against a backend after remote deletions,
|
|
455
|
+
call `clearComments()` and then `loadComments(freshData)` — `loadComments`
|
|
456
|
+
alone replaces by id but never removes.
|
|
190
457
|
|
|
191
458
|
TypeScript definitions ship with the package — no `@types` install needed.
|
|
192
459
|
|
|
@@ -196,6 +463,13 @@ With `persistence: "localStorage"`, every comment (screenshot included) lives
|
|
|
196
463
|
under a single key shared across all pages of your app. Browsers cap that at
|
|
197
464
|
roughly 5 MB, which is on the order of a hundred comments with screenshots.
|
|
198
465
|
|
|
466
|
+
The mode assumes one active tab per page: writes from another tab are
|
|
467
|
+
preserved on the next sync, but two tabs editing the same comment
|
|
468
|
+
concurrently resolve last-write-wins, and a comment deleted in one tab can
|
|
469
|
+
reappear if another tab still holding it in memory saves afterwards. Hosts
|
|
470
|
+
that need real multi-tab editing should persist through the callbacks
|
|
471
|
+
instead.
|
|
472
|
+
|
|
199
473
|
When the quota is reached, HellDots sheds the _automatic_ screenshots of the
|
|
200
474
|
oldest comments and retries, so the comments themselves survive. Screenshots a
|
|
201
475
|
user deliberately attached are never discarded. If you expect heavy use, wire
|
|
@@ -209,8 +483,8 @@ page's CSS cannot leak into it and its styles cannot leak out.
|
|
|
209
483
|
## ESM only
|
|
210
484
|
|
|
211
485
|
This package ships ES modules only. `import` works everywhere — bundlers, Vite,
|
|
212
|
-
Next.js, native `<script type="module">`. There is no CommonJS
|
|
213
|
-
`require("helldots")` will not work.
|
|
486
|
+
Next.js, Node ≥ 18, native `<script type="module">`. There is no CommonJS
|
|
487
|
+
build, so `require("helldots")` will not work.
|
|
214
488
|
|
|
215
489
|
For a plain `<script>` tag with no bundler, a self-contained UMD build is on
|
|
216
490
|
the CDN:
|