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/dist/index.d.ts CHANGED
@@ -24,7 +24,7 @@ export interface CommentAnchor {
24
24
  export type AnchorState = "anchored" | "orphaned" | "inactive";
25
25
 
26
26
  /** RF09 — comment lifecycle state. */
27
- export type CommentStatus = "open" | "in_progress" | "resolved";
27
+ export type CommentStatus = "open" | "in_progress" | "in_review" | "resolved";
28
28
 
29
29
  /** RF3 — comment category. `null` means deliberately unclassified. */
30
30
  export type CommentType = "bug" | "suggestion" | "question" | "improvement";
@@ -32,6 +32,72 @@ export type CommentType = "bug" | "suggestion" | "question" | "improvement";
32
32
  /** RF4 — comment priority. `null` means deliberately unprioritised. */
33
33
  export type CommentPriority = "high" | "medium" | "low";
34
34
 
35
+ /**
36
+ * Aggregate figures over a corpus of comments. Every bucket is present even
37
+ * when empty, so a consumer can index it without guarding — an absent key and
38
+ * a zero would otherwise be indistinguishable.
39
+ */
40
+ export interface CommentMetrics {
41
+ total: number;
42
+ byStatus: Record<CommentStatus, number>;
43
+ /** `unset` holds the comments left deliberately unclassified. */
44
+ byType: Record<CommentType | "unset", number>;
45
+ /** `unset` holds the comments left deliberately unprioritised. */
46
+ byPriority: Record<CommentPriority | "unset", number>;
47
+ /**
48
+ * Comments created per day, oldest first, in `YYYY-MM-DD`. Only days that
49
+ * saw activity get an entry: filling the gaps would put a year of empty
50
+ * buckets between two comments twelve months apart.
51
+ */
52
+ overTime: Array<{ date: string; count: number }>;
53
+ resolution: {
54
+ resolvedCount: number;
55
+ /** Comments that were resolved, reopened and resolved again. */
56
+ reopenedCount: number;
57
+ /** Mean and median of the resolution currently in force, or null when nothing is resolved. */
58
+ averageMs: number | null;
59
+ medianMs: number | null;
60
+ };
61
+ }
62
+
63
+ /** Who performed an audited action, as the host declared them at the time. */
64
+ export interface AuditActor {
65
+ /** From `user.id`, when the host supplies one. Never rendered. */
66
+ id?: string;
67
+ /** The display name at the time of the action. */
68
+ name: string;
69
+ }
70
+
71
+ /**
72
+ * The four actions worth auditing. Replies carry their own author and
73
+ * timestamp already, and reactions are high-frequency signal with no audit
74
+ * value — neither produces an entry.
75
+ */
76
+ export type AuditEventType = "created" | "edited" | "status" | "classified";
77
+
78
+ /** One entry of a comment's append-only audit trail. */
79
+ export interface AuditEvent {
80
+ type: AuditEventType;
81
+ /**
82
+ * ISO timestamp, from the acting client's clock. Merge corpora written on
83
+ * machines whose clocks disagree and an entry can predate the comment it
84
+ * belongs to; durations derived from these are clamped at zero rather than
85
+ * rendered negative.
86
+ */
87
+ at: string;
88
+ actor: AuditActor;
89
+ /** "classified" only: which field moved. */
90
+ field?: "type" | "priority" | "tags";
91
+ /**
92
+ * Both ends of the transition, for "status" and for "classified" on type or
93
+ * priority. `null` is a value, not an absence — it is how type and priority
94
+ * read when deliberately unset. Absent for "created", "edited", and for a
95
+ * tag change, which is a list with no two-value transition.
96
+ */
97
+ from?: string | null;
98
+ to?: string | null;
99
+ }
100
+
35
101
  /** RF2 — environment snapshot taken when the comment was created. */
36
102
  export interface CommentContext {
37
103
  version: 1;
@@ -60,6 +126,11 @@ export interface CommentContext {
60
126
  export type CommentId = string | number;
61
127
 
62
128
  export interface SerializedComment {
129
+ /**
130
+ * Version of this serialized shape. Stamped as 1 by serializeComments;
131
+ * optional because payloads persisted before it existed have none.
132
+ */
133
+ schemaVersion?: number;
63
134
  id: CommentId;
64
135
  text: string;
65
136
  /** ISO timestamp of the last edit; null when never edited. */
@@ -67,8 +138,31 @@ export interface SerializedComment {
67
138
  anchor: CommentAnchor | null;
68
139
  /** location.pathname where the comment was created. */
69
140
  page: string;
141
+ /**
142
+ * Append-only audit trail: who created, edited, moved or reclassified this
143
+ * comment, and when. Optional and absent by default — records written
144
+ * before it existed simply have none, so no migration is involved, and
145
+ * `null` rather than `[]` keeps an untouched corpus free of extra bytes.
146
+ *
147
+ * Every resolution the comment has had is derivable from the `status`
148
+ * entries, which is why no separate resolution history is stored.
149
+ */
150
+ history?: AuditEvent[] | null;
70
151
  replies: CommentReply[];
71
152
  author: string;
153
+ /**
154
+ * Stable identifier of the author, from the `user.id` the host supplied at
155
+ * creation. Optional and absent by default: records written before it
156
+ * existed simply have none, so no migration is involved.
157
+ *
158
+ * Never rendered — `author` is the display name and stays what any UI
159
+ * shows — it travels with the record, so a store that holds nothing but
160
+ * comments renders every author without a lookup. The id is opaque to
161
+ * HellDots: whether it points into the host's user table, into a
162
+ * comments-only database, or nowhere at all is the host's business. What it
163
+ * buys is telling two teammates who share a display name apart.
164
+ */
165
+ authorId?: string | null;
72
166
  createdAt: string;
73
167
  screenshots: string[];
74
168
  status: CommentStatus;
@@ -89,30 +183,124 @@ export interface SerializedComment {
89
183
  /** RF1 — automatic viewport capture (JPEG data-URL). */
90
184
  contextScreenshot: string | null;
91
185
  /**
92
- * RF6 — emoji → authors who reacted. Reserved, not implemented yet.
93
- * Optional and absent by default, so shipping it later needs no migration.
186
+ * RF6 — emoji → the actor keys that reacted (see the `user` option). Always
187
+ * one of the six emoji in the fixed set; anything else is dropped on load.
188
+ * Null when nobody has reacted, so an untouched corpus carries no extra
189
+ * payload.
94
190
  */
95
- reactions?: Record<string, string[]>;
191
+ reactions: Record<string, string[]> | null;
96
192
  }
97
193
 
194
+ /**
195
+ * Everything that can change, as one discriminated union. Switch on `type`
196
+ * and TypeScript narrows the rest of the fields for you.
197
+ *
198
+ * The ten specific callbacks below carry exactly the same events at
199
+ * exactly the same moments; subscribe either way, or both.
200
+ */
201
+ export type ChangeEvent =
202
+ | { type: "comment:created"; comment: SerializedComment }
203
+ | { type: "comment:edited"; comment: SerializedComment }
204
+ | { type: "comment:deleted"; id: CommentId }
205
+ | { type: "comment:status-changed"; comment: SerializedComment }
206
+ /** Type, priority or tags changed. */
207
+ | { type: "comment:updated"; comment: SerializedComment }
208
+ | { type: "comment:anchor-lost"; comment: SerializedComment }
209
+ | {
210
+ type: "reply:added";
211
+ comment: SerializedComment;
212
+ reply: CommentReply;
213
+ }
214
+ | {
215
+ type: "reply:deleted";
216
+ comment: SerializedComment;
217
+ reply: CommentReply;
218
+ }
219
+ | {
220
+ type: "reply:edited";
221
+ comment: SerializedComment;
222
+ reply: CommentReply;
223
+ }
224
+ /** `reply` is null when the reaction is on the root comment. */
225
+ | {
226
+ type: "reaction:toggled";
227
+ comment: SerializedComment;
228
+ reply: CommentReply | null;
229
+ };
230
+
98
231
  export interface CommentOverlayOptions {
99
232
  shortcutKey?: string;
100
233
  shortcutModifier?: "alt" | "ctrl" | "shift";
101
234
  autoInit?: boolean;
102
- /** UI language. Defaults to the browser's language when supported, else "en". */
103
- locale?: "en" | "es";
235
+ /**
236
+ * UI language. "en" and "es" ship today; any other value falls back to
237
+ * English (and a locale missing individual keys falls back per key).
238
+ * Typed as string so a host can pass a runtime-detected code without a
239
+ * cast — an unknown code degrades, it never breaks.
240
+ */
241
+ locale?: string;
104
242
  /** Auto save/restore comments. Default: "none" (host app persists via callbacks). */
105
243
  persistence?: "localStorage" | "none";
106
244
  /** Capture a viewport screenshot and environment snapshot on every new comment. Default: true. */
107
245
  autoScreenshot?: boolean;
108
- /** Identity used as the author of new comments and replies. */
109
- user?: { name: string };
246
+ /**
247
+ * Fetch stylesheets the renderer cannot read, so their web fonts survive
248
+ * into screenshots. Default: false.
249
+ *
250
+ * A cross-origin `<link>` (Google Fonts and friends) throws `SecurityError`
251
+ * on `cssRules`, so its `@font-face` never reaches the capture and the text
252
+ * is rendered in a fallback face. The fallback's metrics differ, which
253
+ * shifts glyphs sideways — a drag selection tight around a few letters then
254
+ * comes back holding the wrong ones. Enabling this re-fetches those sheets
255
+ * (the same URLs the page already loaded, cached per session) so the
256
+ * capture matches the page.
257
+ *
258
+ * Off by default because a comment widget making third-party requests
259
+ * should be the host's decision. Leave it off and captures of such a page
260
+ * stay misaligned; a host can also fix it at the source by self-hosting the
261
+ * font or adding `crossorigin` to the `<link>`.
262
+ */
263
+ embedCrossOriginFonts?: boolean;
264
+ /**
265
+ * Identity used as the author of new comments and replies.
266
+ *
267
+ * `name` is what gets displayed. `id` is optional and never rendered: it
268
+ * is persisted as `authorId` on the comments and replies this user creates,
269
+ * and it is what a reaction is keyed on — so two teammates who share a
270
+ * display name are still told apart. Without it the name is used, and
271
+ * without any `user` at all every actor collapses into one.
272
+ *
273
+ * HellDots authenticates nobody: whatever the host declares here is taken
274
+ * at face value and recorded as-is.
275
+ */
276
+ user?: { name: string; id?: string };
110
277
  /**
111
278
  * Query parameter carrying a comment id in "Copy link" URLs, and read back
112
279
  * on startup to open that comment. Default: "helldotsComment". Override it
113
280
  * when the host already routes on that name.
114
281
  */
115
282
  linkParam?: string;
283
+ /**
284
+ * Called instead of a full-page load when the widget navigates to another
285
+ * page (the inbox's "view on its page" jump). Hand it your SPA router's
286
+ * push so the app's state survives; call `notifyNavigation()` after the
287
+ * route renders.
288
+ */
289
+ navigate?: (page: string) => void;
290
+ /**
291
+ * Run `notifyNavigation()` automatically on popstate (back/forward).
292
+ * Opt-in and popstate-only: pushState routing still needs an explicit
293
+ * `notifyNavigation()` call from the router's hook. Default: false.
294
+ */
295
+ autoDetectNavigation?: boolean;
296
+ /**
297
+ * Single subscription point: fires for every change, alongside whichever
298
+ * specific callback below carries the same event. Handy when a host syncs
299
+ * everything to one endpoint instead of wiring nine functions. A handler
300
+ * that throws is caught and warned about — it never rolls back the
301
+ * mutation that emitted it.
302
+ */
303
+ onChange?: (event: ChangeEvent) => void;
116
304
  /** Fired after a new comment is saved. */
117
305
  onCommentCreated?: (comment: SerializedComment) => void;
118
306
  /** Fired after a reply is added to any comment. */
@@ -131,21 +319,49 @@ export interface CommentOverlayOptions {
131
319
  onCommentStatusChanged?: (comment: SerializedComment) => void;
132
320
  /** Fired after type, priority or tags change on any comment. */
133
321
  onCommentUpdated?: (comment: SerializedComment) => void;
322
+ /**
323
+ * Fired after a reaction is added to or removed from a comment or a reply.
324
+ * `reply` is null when the reaction is on the root comment.
325
+ */
326
+ onReactionToggled?: (
327
+ comment: SerializedComment,
328
+ reply: CommentReply | null
329
+ ) => void;
134
330
  }
135
331
 
136
332
  export interface CommentReply {
137
333
  id: CommentId;
138
334
  text: string;
139
335
  author: string;
336
+ /**
337
+ * Stable identifier of the author, from the `user.id` the host supplied at
338
+ * creation. Optional and absent by default: records written before it
339
+ * existed simply have none, so no migration is involved.
340
+ *
341
+ * Never rendered — `author` is the display name and stays what any UI
342
+ * shows — it travels with the record, so a store that holds nothing but
343
+ * comments renders every author without a lookup. The id is opaque to
344
+ * HellDots: whether it points into the host's user table, into a
345
+ * comments-only database, or nowhere at all is the host's business. What it
346
+ * buys is telling two teammates who share a display name apart.
347
+ */
348
+ authorId?: string | null;
140
349
  timestamp: string;
141
350
  screenshots?: string[];
142
351
  /** ISO timestamp of the last edit; null when never edited. */
143
352
  editedAt?: string | null;
353
+ /**
354
+ * RF6 — emoji → the actor keys that reacted. Same shape and same rules as a
355
+ * comment's; null when nobody has reacted.
356
+ */
357
+ reactions?: Record<string, string[]> | null;
144
358
  }
145
359
 
146
360
  export interface Comment {
147
361
  id: CommentId;
148
362
  text: string;
363
+ /** ISO timestamp of the last edit; null when never edited. */
364
+ editedAt?: string | null;
149
365
  /** Live anchor element; null while the comment is orphaned or inactive. */
150
366
  container: HTMLElement | null;
151
367
  relativeX: number;
@@ -158,8 +374,31 @@ export interface Comment {
158
374
  target?: HTMLElement | null;
159
375
  /** location.pathname where the comment was created. */
160
376
  page: string;
377
+ /**
378
+ * Append-only audit trail: who created, edited, moved or reclassified this
379
+ * comment, and when. Optional and absent by default — records written
380
+ * before it existed simply have none, so no migration is involved, and
381
+ * `null` rather than `[]` keeps an untouched corpus free of extra bytes.
382
+ *
383
+ * Every resolution the comment has had is derivable from the `status`
384
+ * entries, which is why no separate resolution history is stored.
385
+ */
386
+ history?: AuditEvent[] | null;
161
387
  replies: CommentReply[];
162
388
  author: string;
389
+ /**
390
+ * Stable identifier of the author, from the `user.id` the host supplied at
391
+ * creation. Optional and absent by default: records written before it
392
+ * existed simply have none, so no migration is involved.
393
+ *
394
+ * Never rendered — `author` is the display name and stays what any UI
395
+ * shows — it travels with the record, so a store that holds nothing but
396
+ * comments renders every author without a lookup. The id is opaque to
397
+ * HellDots: whether it points into the host's user table, into a
398
+ * comments-only database, or nowhere at all is the host's business. What it
399
+ * buys is telling two teammates who share a display name apart.
400
+ */
401
+ authorId?: string | null;
163
402
  createdAt: string;
164
403
  screenshots: string[];
165
404
  status: CommentStatus;
@@ -180,10 +419,12 @@ export interface Comment {
180
419
  /** RF1 — automatic viewport capture (JPEG data-URL). */
181
420
  contextScreenshot: string | null;
182
421
  /**
183
- * RF6 — emoji → authors who reacted. Reserved, not implemented yet.
184
- * Optional and absent by default, so shipping it later needs no migration.
422
+ * RF6 — emoji → the actor keys that reacted (see the `user` option). Always
423
+ * one of the six emoji in the fixed set; anything else is dropped on load.
424
+ * Null when nobody has reacted, so an untouched corpus carries no extra
425
+ * payload.
185
426
  */
186
- reactions?: Record<string, string[]>;
427
+ reactions: Record<string, string[]> | null;
187
428
  }
188
429
 
189
430
  export declare class CommentOverlay {
@@ -193,7 +434,15 @@ export declare class CommentOverlay {
193
434
  constructor(options?: Omit<CommentOverlayOptions, "autoInit">);
194
435
 
195
436
  toggleCommentMode(): void;
196
- addReply(comment: Comment, text: string): CommentReply;
437
+ /**
438
+ * `screenshots` are data-URLs attached to the reply. Takes the live
439
+ * comment or its id; null when an id does not resolve.
440
+ */
441
+ addReply(
442
+ comment: Comment | CommentId,
443
+ text: string,
444
+ screenshots?: string[]
445
+ ): CommentReply | null;
197
446
  deleteReply(commentId: CommentId, replyId: CommentId): boolean;
198
447
  /** Rewrites a comment's text. False when the id is unknown, the text is blank, or nothing changed. */
199
448
  editComment(id: CommentId, text: string): boolean;
@@ -207,11 +456,62 @@ export declare class CommentOverlay {
207
456
  orphaned: number;
208
457
  inactive: number;
209
458
  };
459
+ /**
460
+ * Re-syncs the widget after a client-side navigation: reclassifies every
461
+ * comment against the new pathname, re-resolves anchors against the new
462
+ * DOM, rebuilds markers and moves the inbox onto the new page. Also the
463
+ * "re-anchor now" primitive for same-path re-renders.
464
+ */
465
+ notifyNavigation(): {
466
+ anchored: number;
467
+ orphaned: number;
468
+ inactive: number;
469
+ };
210
470
  deleteComment(id: CommentId): boolean;
471
+ /**
472
+ * Removes every comment at once (markers, memory, and their persisted
473
+ * entries in localStorage mode). A bulk reset for reconciling against a
474
+ * backend before loadComments — fires no per-comment callbacks.
475
+ */
476
+ clearComments(): void;
477
+ /**
478
+ * Aggregate figures over every comment the widget holds. Unfiltered: the
479
+ * dashboard inside the inbox measures whatever that panel is showing, but a
480
+ * host has no notion of those filters.
481
+ */
482
+ getMetrics(): CommentMetrics;
483
+ /**
484
+ * Downloads the corpus as CSV, one row per comment. Screenshots and the
485
+ * automatic context capture stay out. Defaults to every comment.
486
+ */
487
+ exportCommentsCsv(comments?: SerializedComment[]): void;
488
+ /**
489
+ * Downloads the aggregate figures as CSV in long format — `section, key,
490
+ * value` — so the column count does not change with the corpus.
491
+ */
492
+ exportMetricsCsv(comments?: SerializedComment[]): void;
493
+ /**
494
+ * Opens the browser's print dialog on a report of the figures, which is
495
+ * where "save as PDF" lives. The report is built in its own document, so
496
+ * what prints is the report rather than the host page.
497
+ */
498
+ printMetricsReport(comments?: SerializedComment[], scope?: string): void;
211
499
  setCommentStatus(id: CommentId, status: CommentStatus): boolean;
212
500
  setCommentType(id: CommentId, type: CommentType | null): boolean;
213
501
  setCommentPriority(id: CommentId, priority: CommentPriority | null): boolean;
214
502
  setCommentTags(id: CommentId, tags: string[]): boolean;
503
+ /**
504
+ * RF6 — flips the current actor's reaction on a comment: present, it is
505
+ * removed; absent, it is added. The actor is `user.id ?? user.name`.
506
+ * Returns false when the id or the emoji is unknown.
507
+ */
508
+ toggleCommentReaction(id: CommentId, emoji: string): boolean;
509
+ /** Same contract as toggleCommentReaction, one level down. */
510
+ toggleReplyReaction(
511
+ commentId: CommentId,
512
+ replyId: CommentId,
513
+ emoji: string
514
+ ): boolean;
215
515
  cleanup(): void;
216
516
  }
217
517
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helldots",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Drop-in comment overlay for web apps — click anywhere to leave a comment anchored to that element, with an automatic screenshot and environment capture",
5
5
  "keywords": [
6
6
  "comments",
@@ -53,9 +53,11 @@
53
53
  "build": "node scripts/build.mjs",
54
54
  "size": "node scripts/check-size.mjs",
55
55
  "verify": "npm run lint && npm run typecheck && npm run format:check && npm test && npm run build && npm run size",
56
+ "check:commit": "node scripts/check-commit-msg.mjs",
56
57
  "prepublishOnly": "npm run verify",
57
58
  "changeset": "changeset",
58
- "release": "changeset version"
59
+ "release": "changeset version",
60
+ "prepare": "node scripts/install-hooks.mjs"
59
61
  },
60
62
  "devDependencies": {
61
63
  "@changesets/cli": "^2.31.0",