Releases: xKeCo/helldots
Release list
v0.12.1
Patch Changes
-
b32177b: Give the text fields a readable surface and move the focus cue off the text.
#comment-inputand.thread-inputwere borderless and unpadded, so the
inset 0 -2px 0underline they took on focus was painted through the
descenders of the line being typed β.thread-inputis one line tall. All
three fields now share the inline editor's rounded, padded surface and light
their own edge on focus: a blue border plus a soft glow around the same box.Also removes the horizontal scrollbar the screenshot strip put on the whole
thread. Itsmargin-inline: -4pxmade the strip wider than.thread-scroll,
which isoverflow-y: autoand so scrolls on both axes.
v0.12.0
Minor Changes
-
eb1fd07: Restore visible keyboard focus indicators (WCAG 2.1 AA, 2.4.7 Focus Visible).
Every control the widget exposes to the keyboard now shows one: the toolbar,
the status, type and priority pickers, the menu items, the inbox cards and
filter chips, the marker circles and the screenshot thumbnails take a 2px
ring, and the borderless text fields take an inset underline.They are bound to
:focus-visible, so they appear only when focus arrived by
keyboard β the pointer look is unchanged, which is what the earlier revert of
these rules was protecting.
v0.11.0
Minor Changes
-
072ec69: Editing and deleting are no longer open to everyone
The widget recorded
authorIdon every comment and reply but never read it
back, so the β― menu offered Edit and Delete on all of them to whoever was
looking. It now offers them only on records carrying your own identity.A new
can(action, target)option overrides that rule for moderators, owner
roles or read-only viewers.actionis one of"edit:comment",
"delete:comment","edit:reply"or"delete:reply"; return literaltrue
to allow. The same verdict is readable fromoverlay.can(action, target), so
a delete button in your own chrome can ask the one rule.Only those four actions are gated β status, type, priority, tags, reactions
and replying stay open to everyone. Your own API calls are never refused:
cangoverns clicks inside the widget, notoverlay.deleteComment(id)from
your code.Behaviour changes only for hosts that set
user. Without one, every record
is written by the same anonymous actor and nothing is hidden.This is not authorization β HellDots runs in the page. Keep checking
authorIdagainst the session on your server.
v0.10.0
Minor Changes
- 3886169: Add a marker visibility toggle: an eye button in its own pill beside the
toolbar hides every comment marker to cut visual noise, and shows them
again. The preference persists per browser, and the layer re-shows itself
when the user enters comment mode (button or shortcut) or navigates to a
comment from the inbox. The icon, tooltip, and accessible name flip
together.
Patch Changes
- 110ff07: Drop the hover tooltip from reaction pills. The emoji and the count already say
what a pill is, so a bubble per pill turned a dense row into a wall of popups.
The trigger beside the row keeps its tooltip, and the pills keep their
accessible name andaria-pressedstate. - 87a0e72: A drag comment now anchors to the region it selected, not to the mouseup
pixel. The comment is placed at the rectangle's center, and the anchor
target is the topmost element at that point whose box covers at least 60%
of the region β so a floating panel that happens to sit under the released
mouse (and later closes) can no longer capture the anchor and leave the
marker invisible. Plain-click placement is unchanged. - 5ffb3a9: Show the full author name in a hover tooltip when the meta row truncates it.
The name is measured on hover, so the bubble only appears when the ellipsis
actually hid something β short names stay tooltip-free. - a4bac45: Resolve anchor-matching ties to the deepest matching element β in both the
selector match and the rescue search. The text fingerprint is truncated to
64 characters, so in nested DOMs a parent and its child score identically;
the strict comparison kept the first candidate in document order β always
the ancestor β so the most specific match could never win. Ties between
unrelated elements keep document order, and anchors that resolved uniquely
before resolve identically now.
v0.9.1
Patch Changes
-
1fb00c8: Load
modern-screenshotlazily on the first capture instead of statically.Bundlers now split the renderer (~10 KB gzip) into its own chunk that
downloads when the first capture runs, so apps embedding HellDots no longer
pay for it in their initial bundle β and pages where nobody captures never
fetch it at all. A failed load surfaces through the existingonErrorpath
and is retried on the next capture rather than cached.
v0.9.0
Minor Changes
-
222d567: Declare
modern-screenshotas a peer dependency instead of a direct one.The ESM bundle already treated the renderer as external, but package size
scanners (Bundlephobia, bundlejs) resolve bare imports fromdependencies,
so the published package measured 51.6β54 KB gzip against a 50 KB budget the
bundle itself meets at 44.7 KB. Both scanners attribute peer dependencies to
their own package, so the public measurement now matches the gate.npm 7+, pnpm 8+ and bun install missing peer dependencies automatically, so
npm install helldotsis unchanged. Yarn users must add
modern-screenshotalongside.
Patch Changes
-
b395335: Fix a blue focus ring appearing around the inbox panel when a comment is
opened from a copied link.The panel takes focus as it opens so a screen reader lands inside the dialog.
Opened by a click that is quiet, but a link opens it during page load with no
pointer interaction behind it, which is enough for Chrome's:focus-visible
heuristic to paint its default ring. The panel carriestabindex="-1"and is
never reachable by Tab, so the ring marked nothing anyone could act on.Focus still moves into the dialog; only the ring is suppressed. The focus
rings on the confirm dialog's buttons are untouched. -
5f21bb5: Cap the thread header's meta row at 280px so it stops claiming the full width
of the popover.
v0.8.0
Minor Changes
-
4d352a8: Add
fastCapture, an opt-in that narrows the screenshot renderer's
computed-style enumeration to a curated allow-list instead of every property
the browser exposes. That enumeration is ~91% of a capture's cost and scales
with element count; the list cuts the dominant phase by about 2.7x, measured
pixel-identical to a full capture on the pages it was verified against.Off by default: a property the list does not name is absent from the image,
so the trade belongs to the host. See the README for when to turn it on. -
4d352a8: Add
skipIframeContent, an opt-in that renders embedded documents as blank
instead of cloning them. A same-origin iframe's cost is invisible from the
host page β the renderer clones the whole embedded document, so a page of
242 elements can be a capture of 9 245.The
<iframe>element is kept, so its box and the layout below it stay
where the page put them. Off by default: on a same-origin frame the content
you lose is real. -
4d352a8: Add
captureTimeout, bounding how long one remote asset may hold a capture
up while the renderer re-fetches the page's images and fonts to inline them.The default is unchanged. A dead asset URL stalls a capture for about a
minute β the renderer's own 30 second deadline, paid twice because the number
drives two waits in sequence, one for the image on the page to load and one
for the fetch that inlines it β and the capture still succeeds with that asset
replaced by a placeholder. The wait is bounded rather than multiplied: one
dead asset and ten cost the same.Left at the default because a shorter one drops assets that were only slow
and leaves holes in the image with nothing to say so. Set it if you have
measured your own page. Only a finite positive number is honoured: 0 means
"never give up" to the renderer, andInfinityis coerced to no wait at all. -
4d352a8: Dragging a region no longer waits for the screenshot. The marker and the
comment box appear as soon as the mouse is released, and the crop drops into
a "Capturingβ¦" slot in the attachment strip when its render lands. On a
heavy page that removes over a second between the gesture and being able to
type; Send waits for the crop if you get there first.Adds
capturingScreenshotto both locales.
Patch Changes
-
4d352a8: Fix captures coming back blank on very long pages. Past the browser's canvas
ceiling β 65 535px in a dimension, and an area cap besides β a canvas accepts
its size, hands out a context, takes every draw call and holds no pixels, so
every screenshot off it was empty with nothing said about it.Renders now fit their scale to what the browser will actually paint, verify
that the result holds pixels, and retry smaller if it does not. Pages below
the ceiling are unchanged; past it the capture goes soft rather than blank,
and a render that cannot be produced at all reports throughonErrorinstead
of attaching an empty image. -
4d352a8: Screenshot capture no longer freezes the page. The clone traversal now hands
the main thread back to the browser on an 8 ms budget, so the page keeps
painting and accepting input while a render is in flight β on heavy pages
that render could block for over a second. Wall-clock capture time is
unchanged; what changes is that it is no longer a freeze.
v0.7.0
Minor Changes
-
a616711: Four additions for hosts integrating the widget into a real app.
onCommentModeChanged(active)β comment mode turned on or off, however
it was flipped. The keyboard shortcut is the reason: it never reaches the
host, so an app that has to stand down while somebody picks an element had
no signal at all.onCommentOpened(comment)β somebody opened a comment's thread, from
its marker or from the inbox detail. This is what an unread count is built
on; it does not fire when the inbox merely re-renders. HellDots stores no
read state of its own, because whose read it is depends on an identity only
the host can persist.setUser(user)β replaces the identity new comments, replies and
reactions are attributed to, without rebuilding the widget. For a session
that resolves after mount, or a user switching account. Nothing already
written is rewritten;nullreturns to the anonymous author.exportCommentsCsv()/exportMetricsCsv()now return the CSV text as
well as downloading it, so the rows can be sent somewhere instead of handed
to the user as a file.printMetricsReport()still returns nothing β what
it produces is a print dialog.
-
57e8cda:
transformScreenshotβ swap every image the widget acquires for a string
of your own, so a ~33 KB base64 data URL per comment does not end up in your
database.Called with
(dataUrl, { kind, commentId })for the automatic viewport
capture (kind: "context"), drag-crop regions, and file attachments on
comments and replies (kind: "attachment"). Return the string to store β
typically a URL into your own object storage.It runs at two moments: everything on a comment transforms as the comment is
saved, while a reply attachment transforms when the file is picked, because
addReply()is synchronous. Either way the record may never arrive β an
abandoned draft, or a box dismissed mid-upload β so sweep for unreferenced
blobs rather than assuming every URL you hand back gets stored.Fail-open: a rejection, a throw, or a resolved value that is not a non-empty
string keeps the original data URL and reports the new
onError(error, "transform"). Not called for records passed to
loadComments(), nor for screenshots handed toaddReply()directly.The comment box's submit button is now disabled while a save is in flight,
since that save may be waiting on an upload. -
318a284: Callbacks now say who caused a change and what moved, and a shared link can
ask for the comment it points at.meta.originβ every callback takes one extra trailing argument, and
onChangeevents carry the same fields flattened onto them."user"is
somebody acting inside the widget,"host"is your own code calling a
method. Multi-user apps needed this to stop echoing their own remote writes
back to the server. Existing handlers that ignore the argument are
unaffected.meta.from/meta.to/meta.fieldβcomment:status-changednow
carries both ends of the move (so a reopen is told apart from a resolve),
andcomment:updatedsays which of type, priority or tags it was about.
fieldnarrowsfrom/toin TypeScript.onCommentRequested(id)β fires when a "Copy link" URL points at a
comment the widget does not hold, once per id. Fetch it, hand it to
loadComments(), and the inbox opens on it; return a promise and the link
is retried once it settles. This is what makes loading only the linked
comment possible.DEFAULT_LINK_PARAMandreadCommentLinkParamare also
exported now, for reading the id before an overlay exists.onReady(overlay)β the widget has mounted and every method is safe to
call.loadComments()before that no longer throws: the data is held and
applied at mount, though the counts come back as zeroes until then.onError(error, context)β failures the widget survives but only the
console used to hear about:"capture","storage","load","link".setCommentType,setCommentPriorityandsetCommentTagsnow no-op when
the value does not change, matchingsetCommentStatus. They previously
wrote to storage and emitted an event for a change that did not happen.
Patch Changes
-
6993d78: A save that outlives its own comment box no longer lands on a different one.
The guard after the awaits in_saveCommentNowasked whether a box was
open rather than whether it was still the same one β a window that a host's
transformScreenshotupload stretches to seconds. Dismissing the box, opening
a second comment elsewhere and letting the upload resolve wrote the first
draft onto the second one's anchor and tore the second draft down. The draft
is now snapshotted before the first await and compared by identity.(The same release adds
transformScreenshot; a reply attachment sent while
its upload was in flight could be dropped in the first cut of that feature,
and is not in any published version.)
v0.6.0
Minor Changes
-
599e9f0: Add emoji reactions to comments and replies. One of six fixed reactions
(π π β€οΈ π π π) is added from the emoji button in the action strip β the
same strip the thread popover and every inbox card share β and once something
has been reacted to, a row of pills appears under the comment (below its
screenshot when there is one) with a trailing button for adding one more.
Reacting again with the same emoji removes it, and the row disappears with the
last reaction.The action strip is now split in two: status, type and priority on the left,
and the tools β react, copy context, β― β on the right. Replies carry the same
pair of controls on their meta line.Two new methods,
toggleCommentReaction(id, emoji)and
toggleReplyReaction(commentId, replyId, emoji), plus areaction:toggled
event and itsonReactionToggled(comment, reply)callback βreplyisnull
when the reaction is on the root comment.usergained an optionalid. It is never displayed: it is what a reaction is
keyed on, so two teammates who share a display name do not share a reaction.
Without it the name is used, exactly as authorship already does.Reactions travel in
serializeComments()output as an{ emoji: actorKey[] }
map,nullwhen nobody has reacted, and hostile or stale persisted values are
scrubbed on load. Costs 2 KB gzip and no new dependency. -
1d23150: Persist the host-supplied user identity as
authorIdon comments and replies.user.idalready keyed reactions; it is now also stored alongsideauthoron
everything that user creates, so two teammates who share a display name stay
distinguishable in the record. The id is opaque to HellDots β point it at your
user table, at a comments-only store, or at nothing. The display name is still
the only thing rendered, and it travels with the record, so a store holding
nothing but comments renders every author without a lookup.The field is additive and optional: records written before this change load
unchanged withauthorId: null, and no migration is involved. A non-string id
arriving throughloadCommentsis dropped rather than trusted.One identifier now has exactly one spelling. The id is trimmed β and never
truncated β in every place it lands:authorIdon comments and replies, the
actor.idof each audit entry, and the key a reaction is stored under. They
each normalised it differently before, so a padded or long id could arrive in
three different forms inside one payload.HellDots still authenticates nobody. Whatever the host declares in
useris
recorded as-is. -
d353b7b: Add an append-only audit trail to every comment: who created it, edited its
text, moved its status or changed its classification, and when. It shows as a
foldedHistory (n)disclosure in the inbox detail and rides along in
serializeComments()output ashistory.Resolution time is now derived from that log instead of read off a stored
figure, so a comment that was resolved, reopened and resolved again reports the
duration of the resolution currently in force β and the superseded ones are
listed under Previous resolutions in the same disclosure.Replies and reactions are deliberately not recorded: a reply already carries
its own author and timestamp, and reactions are high-frequency signal with no
audit value. That keeps a typical comment at three to five entries.The field is additive and optional β a corpus written before this change loads
unchanged withhistory: null, and no migration is involved. Entries arriving
throughloadCommentsare scrubbed: an unknown event type or an unparseable
timestamp is dropped rather than trusted.HellDots still authenticates nobody, so the trail records what the host
declared inuserat the moment of each action. It is attributive, not
evidential. -
ba46d13: Add a metrics dashboard and report exports.
The inbox header gains a Metrics button that swaps the list for a
dashboard: totals, resolved and reopened counts, average and median resolution
time, bars per status, type and priority, and a daily distribution. Each bar is
painted in the same colour that value already carries in its picker. It measures
whatever the panel is filtered to;overlay.getMetrics()returns the same
shape over the whole corpus.Three exports, from the dashboard or directly:
overlay.exportCommentsCsv()β one row per commentoverlay.exportMetricsCsv()β the aggregate figures insection, key, valueoverlay.printMetricsReport()β the browser's print dialog, where "Save as
PDF" produces a real PDF
The CSVs are RFC 4180 with a UTF-8 BOM so Excel reads accents correctly, and
values that a spreadsheet would evaluate as formulas are neutralised. No new
dependency: the charts are hand-drawn SVG and the PDF is the browser's own, so
the whole feature costs 4.28 KB gzip.Also fixes
mountStylesconstructing its stylesheet in the calling realm
rather than the target's, which prevented styles from being adopted into a
document other than the caller's. -
6405f7c: Add an In review state to the comment lifecycle, between In progress and
Resolved. It is available in the status picker, in the inbox status filter
and throughsetCommentStatus(id, "in_review"), and it carries the blue that
openused to have.openmoves to an unsaturated off-white grey, so the states somebody actively
moved a comment into are the ones that stand out.CommentStatusgains
"in_review"; stored comments need no migration.
Patch Changes
-
448c0d9: Close the gap under the context block in the inbox detail view. A comment
with no replies still rendered its replies container, and as a zero-height
flex item it collected 24px of the column gap β most visible with the
context block collapsed. -
ae9049d: Fix dropdowns rendering see-through on resolved comments. A resolved card is
dimmed withopacity, which composites it and everything inside it as a single
translucent layer β so the status, type, priority and...menus opened from
one were painted above the context block and still showed it through
themselves. The dim is now lifted while a dropdown inside the card is open.Most visible in the inbox detail view, where the context screenshot sits
directly behind the menu. -
d186cbb: Fix the inbox detail header: its prev/next/close buttons reuse the card
action strip, whosespace-betweenscattered them across the row instead
of grouping them oppositeBack. They now align to the end of the strip,
which keeps its full width. -
494ecbf: Keep dropdowns inside the surface that clips them on the horizontal axis
too. The status picker leads the action strip, so its menu hung 45px past
the left edge of the inbox panel and was cut in half; it now aligns to the
button's left edge when β and only when β it fits that way.
v0.5.0
Single-page app support, one event stream for every change, and a pre-release
audit of the screenshot pipeline that turned up four separate ways a capture
disagreed with the page it was taken from.
npm install helldots@0.5.0Single-page apps
notifyNavigation() re-syncs the widget after a client-side navigation:
comments reclassify against the new pathname, anchors re-resolve against the
new DOM, markers rebuild and the inbox moves onto the new page β deep links
and the cross-page handoff included. The new navigate option routes the
widget's own cross-page jumps through your router instead of a full reload,
and autoDetectNavigation: true covers back/forward automatically.
const overlay = createCommentOverlay({
navigate: (page) => router.push(page),
});
router.afterEach(() => overlay.notifyNavigation());One subscription for every change
onChange fires for every mutation, typed as a discriminated union on
event.type β handy when you sync everything to one endpoint instead of
wiring nine callbacks. The existing callbacks are unchanged and keep firing at
the same moments; this is additive. A handler that throws is now caught and
warned about instead of propagating out of the mutation that already happened.
Screenshots that match the page
A screenshot is not a screen grab β the browser exposes no way to rasterize
the painted page from JavaScript, so the capture is a re-render of the DOM,
and anything the re-render gets wrong moves content relative to what you saw.
Four such defects are fixed here:
- The render was anchored to
<body>, where the user-agent's 8px margin
reappears, so every element in normal flow landed 8px off and the last 8px
fell outside the canvas. - Pages shorter than the viewport came out with a solid black band where the
render did not reach. - A strict
style-srcContent Security Policy killed the capture outright. - A web font served from a cross-origin stylesheet never reached the clone, so
captured text reflowed into a fallback face β and its different metrics made
drag selections over text come back holding the wrong glyphs.
That last one ships behind a new opt-in option, embedCrossOriginFonts. Most
pages want the cheaper fix instead: add crossorigin to the font <link>, or
self-host it. See Web fonts in screenshots in the README.
Also in this release
addReplyaccepts a comment id as well as the live object, and the new
clearComments()wipes everything at once β the bulk reset for reconciling
against a backend before a freshloadComments.- The inbox detail's Context section folds away, like the thread popover's.
- The widget renders under a strict
style-srcCSP: styles are delivered as
constructed stylesheets. - Dropdowns open upward when there is no room below them.
- Accessibility: the keyboard contract
role="menu"promises, focus handling
in the lightbox and inbox, and keyboard-operable screenshot thumbnails. - Performance: the inbox list reconciles by comment id instead of rebuilding,
and keeps its scroll position. - Internals:
overlay.jssplit intocapture-flow,popover-controllerand
marker-engine. No public API change.
Upgrading from 0.4.0
No breaking changes.
Only if you pass a custom strings object: one new key, modifierShift
("Shift"). Nothing was removed. The bundled en and es locales are
complete.
Full list of changes in CHANGELOG.md.