npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@askelo/browser

v0.1.1

Published

Framework-agnostic browser SDK for the Askelo support widget. Loads the widget and returns a handle you can open, close and subscribe to.

Readme

@askelo/browser

The Askelo support widget, for any browser app. Loads the widget and hands back something you can open, close and subscribe to.

Using React? @askelo/react wraps this in components and a hook.

npm install @askelo/browser

Usage

import { loadAskeloWidget } from "@askelo/browser";

const widget = await loadAskeloWidget({ widgetId: "your-widget-id" });

widget.open();
widget.on("unread", (count) => updateYourBadge(count));

Your widget id is in Channels → Widget in the dashboard. Add the origin your app runs on to that widget's allow-list, or it will refuse to start.

Everything the widget looks like — colour, position, greeting, handoff labels — is resolved from the widget id on the server, so there is nothing else to configure and a settings change never needs a redeploy.

Your own launcher

const widget = await loadAskeloWidget({ widgetId: "…", hideLauncher: true });

document.querySelector("#support")!.addEventListener("click", () => widget.toggle());
widget.on("unread", (count) => { badge.textContent = count > 0 ? String(count) : ""; });

hideLauncher also suppresses the greeting bubble and the unread badge, since both are drawn against the launcher's corner. The unread event still fires, so your own trigger can carry the count.

Styling

There are two surfaces, and they are customized in two different ways because they live in two different places.

The chrome — CSS on your own page

The launcher, the floating card and the greeting bubble are drawn in your DOM, so you style them with ordinary CSS. Set any of these custom properties anywhere they cascade — :root, a wrapper, a media query, your dark-mode class:

:root {
  --askelo-launcher-size: 72px;
  --askelo-launcher-offset-y: 120px;   /* clear of your cookie banner */
  --askelo-panel-radius: 4px;
}

| Property | Default | What it does | | --- | --- | --- | | --askelo-launcher-size | 60px (54px under 768px) | Launcher width and height | | --askelo-launcher-offset-x | 24px (20px under 768px) | Distance from the left/right edge | | --askelo-launcher-offset-y | 24px (20px under 768px) | Distance from the top/bottom edge | | --askelo-launcher-radius | 50% | Launcher corner radius | | --askelo-launcher-background | gradient from your brand colour | Launcher fill | | --askelo-launcher-shadow | two-layer soft shadow | Launcher shadow | | --askelo-panel-width | 420px (fills the screen under 768px) | Floating panel width | | --askelo-panel-height | 640px (fills the screen under 768px) | Floating panel height | | --askelo-panel-offset | 104px | Gap between the panel and the viewport edge | | --askelo-panel-radius | 20px (16px under 768px) | Panel corner radius | | --askelo-panel-shadow | two-layer deep shadow | Panel shadow | | --askelo-greeting-width | 320px | Greeting bubble width | | --askelo-greeting-background | white | Greeting bubble fill | | --askelo-greeting-radius | 16px | Greeting bubble corner radius | | --askelo-greeting-shadow | soft shadow + hairline | Greeting bubble shadow | | --askelo-z-index | 2147483000 | Base of the widget's stack — the launcher sits at +2, the panel at +1, the greeting at -1. Lower it to put your own modal on top. |

Set a property once and it applies at every breakpoint: the responsive defaults above only apply when you have not set one. That is deliberate — a launcher you sized at 72px should stay 72px on a phone, because that is what you asked for.

The panel — the theme option

The panel is a cross-origin iframe, so no CSS of yours reaches inside it. Three values cross the boundary instead:

const widget = await loadAskeloWidget({
  widgetId: "your-widget-id",
  theme: {
    accent: "#ff0055",
    fontFamily: '"Söhne", system-ui, sans-serif',
    colorScheme: "dark",
  },
});

widget.setTheme({ colorScheme: "light" });   // later, e.g. your own theme toggle
widget.setTheme(null);                        // back to CSS properties, then the dashboard

| Key | Default | Notes | | --- | --- | --- | | accent | your dashboard setting | Overrides it for this page only | | fontFamily | the platform UI stack | Nothing is downloaded — name a face the page already loads | | colorScheme | "auto" | "light" / "dark" force it; "auto" follows the visitor's OS |

You can also set these from CSS, which is what a plain <script> tag install uses — they are read off your root element when the widget loads:

:root {
  --askelo-accent: #ff0055;
  --askelo-font-family: "Söhne", system-ui, sans-serif;
  --askelo-color-scheme: dark;
}

CSS is read once, at load; setTheme() applies at any time and wins over it. If your page has a live theme toggle, drive it with setTheme().

The two accents are separate on purpose. --askelo-accent themes the panel's interior; --askelo-launcher-background themes the launcher. Setting your brand colour in the dashboard drives both, which is what nearly everyone wants — reach for these only when one surface has to differ from the other on a particular page.

What is not themeable, and why

Spacing, radii and per-element colours inside the panel are deliberately not exposed. Everything that crosses the iframe boundary is an API we then have to keep working, and the panel's interior is already inside a box whose outside you control completely. If you need something specific there, ask — it is a better conversation than a token we can never change.

A chat on your own page

For a support page, a help sidebar, or anywhere the corner bubble is the wrong shape:

import { mountAskeloChat } from "@askelo/browser";

const { chat } = await mountAskeloChat("#support-chat", { widgetId: "your-widget-id" });

The container needs a height — the chat fills it and brings no size of its own. The floating launcher is hidden by default (pass showLauncher: true to keep it), since a page that has embedded the conversation already has a way in.

It is the same widget and the same conversation as the launcher, so a visitor who starts here and later opens the bubble elsewhere on your site is in one conversation.

chat.isOnScreen() reports whether the visitor can actually see it. That is also what the widget reports as presence, and presence decides whether an agent's reply is also emailed — a chat scrolled off the page reads as away, so the reply reaches their inbox too.

If you already have a widget handle, widget.mountInline(element) does the same thing without loading a second one.

Telling the widget who your visitor is

If your app already knows who is signed in, say so — the widget stops asking them to type their own email address to reach a human, and the agent who picks the conversation up sees their name.

const widget = await loadAskeloWidget({ widgetId: "…" });

widget.identify({
  externalId: user.id,
  email: user.email,
  name: user.name,
});

Call it again when they sign in, or when the account they are acting as changes. Passing user to loadAskeloWidget does the same thing at load, so the first identify costs you no extra round trip.

An identity is a prefill and a label. It fills in the contact details on a support case and names the person to your agent. It is not authentication and it never widens what the widget can see.

Verified identities

Because the call above happens in a browser, by itself it is a claim: anyone who can open the page can make it. Turn on identity verification for your widget (Channels → Widget → your widget → Identity verification) and Askelo will only accept a claim your server has signed.

// on your server
import { createHmac } from "node:crypto";

const subject = user.id ?? user.email;          // externalId first, else email
const signature = createHmac("sha256", process.env.ASKELO_IDENTITY_SECRET)
  .update(subject)
  .digest("hex");
// in the browser, with the signature your server sent down
widget.identify({ externalId: user.id, email: user.email, name: user.name, signature });

The contract is HMAC-SHA256(key = your widget's secret, message = subject), hex-encoded and lowercase, where subject is the externalId — or the email when there is no externalId. Compute it on the server: a secret your frontend can read is a secret any visitor can read.

Only that one field is proven. With an externalId, email rides beside it unsigned — it prefills the case, but it is not trusted for anything an action reads, like a Stripe or Shopify lookup scoped to the visitor's verified email.

Proving both fields at once (v2)

If you need externalId and email trusted together, sign the versioned message instead and send signatureVersion: 2 alongside:

// on your server
import { createHmac } from "node:crypto";

function v2Message(externalId: string, email: string) {
  const normalizedEmail = email.trim().toLowerCase();
  return `v2\nuid:${externalId.length}:${externalId}\nemail:${normalizedEmail.length}:${normalizedEmail}`;
}

const signature = createHmac("sha256", process.env.ASKELO_IDENTITY_SECRET)
  .update(v2Message(user.id, user.email))
  .digest("hex");
// in the browser — identical shape, signatureVersion just rides along
widget.identify({
  externalId: user.id,
  email: user.email,
  name: user.name,
  signature,
  signatureVersion: 2,
});

Both fields are length-prefixed in the message so that no value either one can contain — a newline, a colon — can shift where uid ends and email begins. email is trimmed and lowercased before it enters the message; sign it the same way on your server. Existing integrations that sign only externalId or only email keep working unchanged — v2 is opt-in per identify() call, not a widget-wide setting, and the same secret verifies both.

Once a secret is set, an unsigned or wrongly signed identity is refused, not recorded as unverified. So deploy your signing code first, or turn verification on after your integration is live. Until you set one, identities are accepted and your agents see them marked as unverified.

API

loadAskeloWidget(options)

| Option | Type | Description | | --- | --- | --- | | widgetId | string | Required. | | cdnUrl | string | Only if you were given a dedicated CDN origin. | | hideLauncher | boolean | Suppress the built-in launcher. | | timeoutMs | number | Default 20000. 0 waits indefinitely. | | user | AskeloVisitor | Identify the visitor as part of loading. |

mountAskeloChat(target, options) takes the same options plus showLauncher, and resolves with { widget, chat }.

Resolves with a handle:

interface AskeloWidgetHandle {
  widgetId: string;
  version: string;
  open(): void;
  close(): void;
  toggle(): void;
  isOpen(): boolean;
  unreadCount(): number;
  setLauncherHidden(hidden: boolean): void;
  identify(user: AskeloVisitor): Promise<void>;
  mountInline(container: HTMLElement): AskeloInlineChat;
  on(event: "open" | "close" | "unread" | "destroy", handler): () => void;
  destroy(): void;
}

interface AskeloVisitor {
  externalId?: string;
  email?: string;
  name?: string;
  signature?: string;
  /** Absent or 1 is the default contract above; 2 is the versioned one that proves externalId and email together. */
  signatureVersion?: number;
}

identify() resolves once the identity has reached the widget, not once the server has accepted it — the widget's iframe does not exist until the visitor first opens the panel, so for a call made at login there is nothing yet to round-trip to. The widget holds the identity and sends it when there is a conversation to attach it to. A signature the server refuses is reported in the browser console.

Calling loadAskeloWidget twice for the same widget returns two handles on one widget, not two widgets. destroy() releases a handle; the widget itself is torn down once the last one is released. A widget that was already running when you called — because the page also carries the <script> snippet — is adopted and never destroyed by this package.

interface AskeloInlineChat {
  isOnScreen(): boolean;
  unreadCount(): number;
  onUnread(handler: (count: number) => void): () => void;
  identify(user: AskeloVisitor): void;
  destroy(): void;
}

Errors

Rejections are an AskeloError with a code: invalid_widget_id, invalid_cdn_url, script_load_failed, bootstrap_failed, timeout, no_browser, invalid_container, or inline_unsupported (the widget runtime this browser cached predates inline chat — redeploy, or wait for the cached loader to expire).

import { AskeloError, loadAskeloWidget } from "@askelo/browser";

try {
  await loadAskeloWidget({ widgetId });
} catch (error) {
  if (error instanceof AskeloError && error.code === "bootstrap_failed") {
    // wrong widget id, or this origin is not on the widget's allow-list
  }
}

destroyAllAskeloWidgets()

Removes every widget this package loaded, immediately. An escape hatch for a "log out and clear everything" path; prefer destroying handles otherwise.

Notes

  • Server-safe to import, not to call: loadAskeloWidget rejects with no_browser outside a browser rather than touching document. Call it from an effect, or after a typeof window !== "undefined" check.
  • ESM and CJS builds are both published, with types for each.
  • Assets are fetched from Askelo's CDN at runtime and their integrity is verified there (signed manifest, SRI-pinned bundle), so this package stays a few KB and gets fixes without an npm upgrade.