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

@sendsay-ru/guidely

v0.7.2

Published

Framework-agnostic onboarding library core.

Readme

@sendsay-ru/guidely

Framework-agnostic onboarding library core for TypeScript applications.

@sendsay-ru/guidely includes config validation, the tour engine, a Shadow DOM renderer, theme customization, localization, tooltip positioning, modal steps, hotspot beacons, a contextual auto-promotion scheduler, and flows that start by themselves when the app opens, when an element appears, or when the user opens a matching page.

Install

npm install @sendsay-ru/guidely
yarn add @sendsay-ru/guidely

Usage

import { createGuidely, type TFlowState } from "@sendsay-ru/guidely";

const stateByFlow = new Map<string, TFlowState>();

const guidely = createGuidely({
  config: {
    version: 1,
    flows: [
      {
        id: "welcome-tour",
        startsAt: "2026-08-01T00:00:00.000Z",
        steps: [
          {
            id: "welcome",
            type: "modal",
            content: {
              en: { title: "Welcome", body: "Let us show you around." },
            },
          },
          {
            id: "profile",
            type: "tooltip",
            target: { type: "data-id", value: "profile-button" },
            placement: "bottom",
            content: {
              en: { title: "Profile", body: "Manage your account here." },
            },
          },
        ],
      },
    ],
  },
  adapter: {
    getState(flowId, context) {
      // Use context.startsAt if your app gates tours by date.
      return stateByFlow.get(flowId) ?? null;
    },
    setState(flowId, state) {
      stateByFlow.set(flowId, state);
    },
  },
});

await guidely.start("welcome-tour", { force: true });

Targets with { type: "data-id", value: "profile-button" } resolve to data-guidely-id="profile-button" by default. The attribute prefix can be changed through attributePrefix in the config.

Use scope: "document" or scope: "frame" when more than one resolver can contain the same target id. Unscoped targets remain supported for backwards compatibility.

Guidely also accepts a custom targetResolver at runtime. Resolvers turn authored target config into an abstract resolved target API, so the renderer does not need direct access to an HTMLElement. This is useful when a target lives in another document, a strict iframe, or any surface where the host app can provide geometry and events without exposing DOM nodes.

Hotspot steps render a beacon marker and keep the tooltip closed until the user clicks, focuses, or hovers the marker. Add initiallyOpen: true to a hotspot step to show the tooltip immediately.

Keyboard and Focus

Modal and tooltip steps block the rest of the page, so they move focus into the step and keep Tab inside it. Escape skips such a step only when its skip action is enabled.

Hotspot steps leave the page interactive. Tab keeps its normal order, hovering or focusing the beacon expands the hint without moving focus, clicking the beacon moves focus into the hint, and Escape collapses the hint back to the beacon without ending the flow.

Guidely handles Escape, Enter, and Space only when they come from its own UI, so host shortcuts keep working. When a step closes, focus goes back to the element that had it before the step only if focus is still inside Guidely, or if a modal or tooltip step dropped it on the page body. Focus the user has moved elsewhere in the page stays where it is.

A renderer created with createRenderer({ manageFocus: false }) never moves, traps, or restores focus. Visual editors use it for previews that update while the author types elsewhere.

Contextual Auto-promotion

Set a flow trigger to { type: "target" } to let Guidely promote it when its first-step target becomes available. The scheduler owns target discovery, priority, one shared prompt slot, prompt lease, availability-cycle deduplication, cooldown, and optional session frequency limits. The host only supplies config, a state adapter, and optional target resolvers.

const config = {
  version: 1,
  autoPromotion: {
    promptLeaseMs: 15_000,
    cooldownMs: 60_000,
    minIntervalMs: 1_000,
  },
  flows: [
    {
      id: "new-toolbar",
      trigger: { type: "target", priority: 10 },
      steps: [
        {
          id: "toolbar",
          type: "hotspot",
          target: { type: "data-id", value: "toolbar", scope: "frame" },
          content: { en: { body: "Try the new toolbar." } },
        },
      ],
    },
  ],
};

Only one prompt beacon is rendered at a time. A prompt is discovery UI, not an active flow: snapshot.promptedFlow is set, activeFlow stays null, and no in_progress state is persisted. Clicking, focusing, or hovering the beacon starts the flow normally. If the prompt is ignored until promptLeaseMs, the slot can move to another eligible flow. That candidate is considered consumed for the current continuous target-availability cycle: expiration of cooldownMs alone does not show it again. When the target becomes unavailable and later reappears, the candidate is rearmed; cooldownMs still prevents an immediate repeat. Target loss removes the prompt immediately and starts the same cooldown. Completed, skipped, and not-applicable flows are never promoted.

maxPromptsPerSession is an optional hard cap across all candidates for the lifetime of one Guidely runtime. Omit it when targets can first appear later in the session; availability-cycle deduplication prevents continuously visible targets from endlessly reclaiming the prompt slot.

Eligibility is read from the state adapter once per candidate during a scheduler session and is updated immediately when the same Guidely instance completes or skips a flow. For efficient DOM discovery, data-id targets use one shared observer that filters to the configured data attribute and relevant inserted subtrees. CSS selectors require conservative attribute observation, so prefer data-id targets for contextual auto-promotion.

If a promoted flow is stopped or fails to start, that candidate stays consumed for its current availability cycle and enters its configured cooldown instead of immediately reclaiming the prompt slot.

Release Announcements

Set a flow trigger to { type: "immediate" } to open it right after the runtime starts, for example a modal that introduces the features of a recent release. Guidely decides once per runtime session, before it shows any contextual prompt:

  • immediate flows are ordered by startsAt, newest first;
  • flows for which the adapter returns "not_applicable" are passed over, so the host decides which announcements apply to the user;
  • the freshest remaining flow starts, or resumes when it is still in_progress;
  • if that flow is already completed or skipped, nothing starts. Older announcements count as superseded, so they never play one after another, now or on later visits.

Flows without a valid startsAt count as the oldest. With autoPromote: false, immediate flows do not start automatically, the same as contextual prompts.

Announcements for one part of the app

An announcement about one screen, or about an app inside an iframe, should start when the user opens it rather than when the runtime starts. Give the trigger a target: the flow then starts once that element appears, for example a toolbar of the email editor in the frame scope:

trigger: {
  type: "immediate",
  target: { type: "data-id", value: "editor-toolbar", scope: "frame" },
}

Flows with the same target form their own group, and the rules above apply to each group separately: an editor announcement is not superseded by a newer release of the whole app. The decision is made again each time the element appears, so a user who leaves the editor before an announcement could start sees it next time. When announcements of several groups are due at once, the freshest starts first and the others follow after it ends.

const config = {
  version: 1,
  flows: [
    {
      id: "release-2026-09",
      startsAt: "2026-09-01T00:00:00.000Z",
      trigger: { type: "immediate" },
      steps: [
        {
          id: "blocks",
          type: "modal",
          content: {
            en: {
              title: "New multi-section blocks",
              body: "Build complex email designs faster.",
              image: { src: "/release/2026-09/blocks.png", alt: "Blocks catalog" },
            },
          },
        },
      ],
    },
  ],
};

Modal steps render an optional image from the localized content, one progress dot per step in multi-step flows, the title and body, and the step actions. In multi-step flows the skip action is a close button; single-step modals leave it out and are dismissed with their primary button. The back button is hidden on the first step. Line breaks in a modal title and body are kept. Use labels in the localized content to rename the buttons of one step, for example labels: { done: "Got it" }.

Route Tours

Set a flow trigger to { type: "route", path } to start it when the user opens a matching page, for example tips for the campaign page:

trigger: { type: "route", path: "/campaigns/:id" }

Guidely does not watch the address bar: the application reports the page it shows. Pass the current path as path when you create Guidely, and call setPath after each navigation. React applications pass the path prop of GuidelyProvider instead. Report the path your router matches, so hash and memory routers work too.

const guidely = createGuidely({ config, adapter, path: location.pathname });

router.subscribe((location) => {
  guidely.setPath(location.pathname);
});

Paths use the pattern syntax of react-router:

  • /campaigns matches that page only;
  • :id matches any one segment, so /campaigns/:id matches /campaigns/42, but not /campaigns or /campaigns/42/edit;
  • a final * matches the rest of the path, including nothing, so /campaigns/* matches /campaigns and every page under it.

Letter case, percent-encoding, and a trailing slash do not matter, and the query and hash are not part of a path. The config schema rejects paths that do not start with /, paths with ? or #, and a * anywhere but at the end. matchRoutePath(path, pathname) applies the same rules, for example in tests.

Route flows follow the rules of release announcements. Flows with the same path form a group, and paths that match the same pages count as the same, so /campaigns/:id and /Campaigns/:slug/ share a group:

  • the freshest applicable flow of the group starts, and older ones count as superseded once it is completed or skipped;
  • the decision is made on each visit, that is, when the user comes to a matching page from a page that does not match. Moving between matching pages continues the visit;
  • a flow that cannot start because another flow is running starts after that flow ends, if the user is still on a matching page;
  • leaving the page does not stop a flow that has started. Steps with targets stop by themselves when their target disappears.

Route flows do not start until a path is reported, and with autoPromote: false they do not start at all, the same as immediate flows and contextual prompts.

Config Shape

Guidely configs are validated at runtime with parseConfig. Defaults are applied by the schema, so you can omit optional fields in authored configs.

Type configs you write by hand with TGuidelyConfigInput, where fields with defaults are optional:

import type { TGuidelyConfigInput } from "@sendsay-ru/guidely";

export const config = {
  version: 1,
  flows: [
    {
      id: "release",
      trigger: { type: "immediate" },
      steps: [{ id: "news", type: "modal", content: { en: { body: "What's new" } } }],
    },
  ],
} satisfies TGuidelyConfigInput;

TGuidelyConfig is the parsed shape that parseConfig returns: every default is filled in there, so it also requires fields a step does not use, such as placement of a modal.

The authored shape:

type TGuidelyConfigInput = {
  version: 1;
  defaultLocale?: string; // default: "en"
  attributePrefix?: string; // default: "guidely"
  autoPromotion?: {
    promptLeaseMs?: number; // default: 15000
    cooldownMs?: number; // default: 60000
    minIntervalMs?: number; // default: 1000
    maxPromptsPerSession?: number;
  };
  flows: TFlow[];
};

type TFlow = {
  id: string;
  startsAt?: string; // optional flow metadata passed to adapter.getState
  trigger?:
    | { type: "manual" }
    | { type: "route"; path: string } // a page path pattern, see Route Tours
    | { type: "target"; priority?: number }
    | { type: "immediate"; target?: TTarget }; // default: manual; priority default: 0
  highlight?: {
    mode?: "blocking" | "interactive"; // default: blocking
    padding?: number; // default: 8
  };
  steps: TStep[];
};

type TTarget = {
  type: "data-id" | "css";
  value: string;
  scope?: string;
};

type TStep = {
  id: string;
  type: "modal" | "tooltip" | "hotspot";
  target?: TTarget; // required for tooltip and hotspot
  placement?: "top" | "bottom" | "left" | "right" | "auto"; // default: auto
  initiallyOpen?: boolean; // hotspot only, default: false
  content: Record<
    string,
    {
      title?: string;
      body: string;
      image?: { src: string; alt?: string }; // shown by modal steps
      labels?: { back?: string; next?: string; skip?: string; done?: string; close?: string };
    }
  >;
  actions?: {
    prev?: boolean; // default: false
    next?: boolean; // default: true
    skip?: boolean; // default: true
  };
  advanceOn?: { event: "click" | "input" | "change" | "submit" };
  waitForTarget?: {
    timeout?: number; // default: 5000
    onTimeout?: "skip" | "abort"; // default: skip
  };
};

Use advanceOn when a step should advance after an event on the real target element. Use waitForTarget when a target may appear later after routing, lazy rendering, or data loading.

API

const guidely = createGuidely({
  config,
  adapter,
  onEvent,
  locale,
  labels,
  theme,
  container,
  targetResolver,
  autoPromote, // set false to disable automatic prompts while keeping manual starts available
  path, // the page the user is on, for route triggers
});

const result = await guidely.start(flowId, { force, startAtStep });
// { started: true, resumed: boolean }
// or { started: false, reason: "completed" | "skipped" | "not_applicable" | "destroyed" }
guidely.next();
guidely.prev();
guidely.skip();
guidely.complete();
guidely.goTo(stepId);
guidely.stop();
guidely.setLocale(locale);
guidely.setPath(path); // after the user moves to another page
guidely.getActiveFlow();
guidely.subscribe(listener);
guidely.subscribeEvents(listener);
guidely.getSnapshot();
guidely.destroy();

Custom Target Resolvers

The default resolver supports data-guidely-id targets and CSS selectors in the current document. A custom resolver can add another target source while keeping the default DOM resolver available as a fallback.

The default resolver returns live targets: they stay bound to the target config rather than to one element, so geometry, advanceOn events, and focus follow an element that the application re-mounts with the same id. Target events are delegated from the document in the capture phase. Custom resolvers can reuse this behavior by delegating DOM targets to the exported domTargetResolver, while createDomResolvedTarget(element, target) stays bound to the element it receives.

Only rendered elements count as DOM targets: an element needs a non-empty layout box and must not be hidden with visibility. When several elements match, the first rendered one is used and kept while it stays rendered, so a highlighted target does not jump. Showing or hiding a target with display is detected through ResizeObserver. Opacity is ignored on purpose, so controls revealed on hover remain valid targets. A target hidden while its step is shown counts as lost, the same as a removed one. The step stops only if the target stays lost for 300 ms, so an element that the application re-mounts or briefly hides keeps its step. Stopping keeps the persisted in_progress state.

import { createGuidely, type TTargetResolver } from "@sendsay-ru/guidely";

const targetResolver: TTargetResolver = {
  resolve(target) {
    if (target.type !== "data-id" || target.value !== "remote-button") {
      return null;
    }

    return {
      target,
      getRect: () => ({
        top: 80,
        right: 220,
        bottom: 120,
        left: 120,
        width: 100,
        height: 40,
      }),
      watchRect: (onChange) => {
        // Subscribe to whatever moves or resizes the remote target.
        return () => undefined;
      },
      subscribeEvent: (eventName, handler) => {
        // Subscribe to target events such as advanceOn click/input/change/submit.
        return () => undefined;
      },
    };
  },
};

const guidely = createGuidely({
  config,
  adapter,
  targetResolver,
});

Resolved targets implement:

type TResolvedTarget = {
  getRect: () => TRect | null;
  watchRect: (onChange: () => void) => () => void;
  subscribeEvent: (eventName: string, handler: () => void) => () => void;
  focus?: () => void;
  metadata?: Record<string, unknown>;
};

Resolvers can also implement watchAvailability(target, context, onChange). It must report the initial availability and then call onChange(isAvailable) only when the target becomes available or unavailable. Availability must not be coupled to rectangle movement. watchRect is created only while a target is actively rendered.

Resolvers that own targets the page cannot reach, such as iframe elements, can also implement startPicking(listener). Visual editors call it to let authors pick such targets: the resolver reports hovered and picked elements through listener.onHover, listener.onPick, and listener.onCancel, and returns a function that stops picking. createCompositeTargetResolver forwards picking to every resolver that supports it.

For cross-origin iframe targets, use @sendsay-ru/guidely-frame-bridge, which implements this resolver API over postMessage.

State Adapter

Guidely delegates persistence to the host application:

type TGuidelyStateAdapter = {
  getState: (
    flowId: string,
    context: TGuidelyStateAdapterContext,
  ) => TFlowState | null | Promise<TFlowState | null>;
  setState: (flowId: string, state: TFlowState) => void | Promise<void>;
};

type TGuidelyStateAdapterContext = {
  startsAt?: string;
};

This keeps the core free of storage assumptions. You can use memory, local storage, a server API, or an existing product state layer.

Guidely waits for getState before starting a flow, so eligibility and persisted terminal states are resolved before anything is rendered. Once the flow is eligible, Guidely renders it immediately and queues the initial in_progress state without waiting for asynchronous setState to finish. A failed write emits an error event but does not hide or roll back the active flow.

setState calls are serialized per flow, so asynchronous adapters cannot persist rapid step transitions in reverse order. While an asynchronous state read in start() is in progress, another start() returns { started: false, reason: "not_ready" }.

startsAt is optional flow metadata and is passed to adapter.getState as context.startsAt. The host application can use it to decide whether to return stored state, null, or a "not_applicable" flow state before Guidely renders anything.

TFlowState.status can be "not_started", "in_progress", "completed", "skipped", or "not_applicable". Use "not_applicable" when the host application has determined that a flow does not apply to the current user or context. Guidely will not start completed, skipped, or not applicable flows unless start is called with force: true.

Events

The library does not send telemetry. Pass onEvent during creation or call subscribeEvents(listener) to receive runtime events such as flow:prompt, flow:prompt-engage, flow:prompt-dismiss, flow:start, flow:complete, flow:skip, step:show, step:advance, step:target-not-found, and error.

flow:start includes source: "manual" for start() calls, "prompt" when the user engaged a prompt, "immediate" for flows with an immediate trigger, and "route" for flows with a route trigger.

Theming and Localization

Pass theme to override colors, radii, spacing, typography, tooltip, and beacon tokens. Pass labels to override system button labels, or labels in localized step content to change them for one step. Step content falls back from active locale to defaultLocale and then to the first available localized entry.

Theme groups are merged with defaults by individual fields:

import type { TThemeOverride } from "@sendsay-ru/guidely";

const theme = {
  colors: {
    primary: "#2563eb",
    onPrimary: "#ffffff",
    surface: "#ffffff",
    onSurface: "#111827",
    overlay: "rgba(17, 24, 39, 0.56)",
    beacon: "#f59e0b",
    secondary: "#f1f5f9",
    muted: "#94a3b8",
    subtle: "#e2e8f0",
  },
  radius: {
    sm: "4px",
    md: "8px",
    lg: "12px",
  },
  spacing: {
    sm: "8px",
    md: "16px",
    lg: "24px",
  },
  typography: {
    fontFamily:
      'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
    fontSize: "14px",
  },
  zIndex: 2147483000,
  tooltip: {
    maxWidth: "320px",
    arrowSize: "8px",
  },
  beacon: {
    size: "16px",
    pulse: true,
  },
} satisfies TThemeOverride;

The default typography uses a system font stack to avoid invisible text while webfonts load. If you override theme.typography.fontFamily with a custom webfont, define that font in the host application with font-display: swap or optional.