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

@danielbachhuber/bb-plugin-component-library

v1.1.0

Published

UI that several plugins draw the same way, starting with the sync status in a page's title bar.

Readme

component-library

UI that several plugins draw the same way. A plugin imports it from here rather than keeping its own copy in components/ui/, so a fix lands in every plugin at once and the plugins cannot drift apart.

Something belongs here once two plugins draw it the same way. Each entry is a directory holding the component, its tests, and a story for each of its states, and has a section below saying what it is for, where it goes, and what not to do with it.

| Entry | Import | What it is | | --- | --- | --- | | Sync status | @danielbachhuber/bb-plugin-component-library/sync-status | How long ago a page last synced, and a Refresh button, for the page's title bar, with what the past hour of syncs cost on GitHub behind the label | | Sidebar count | @danielbachhuber/bb-plugin-component-library/sidebar-count | The counts beside a page's name in bb's sidebar: urgent rows in a red circle, optionally rows due soon in amber beside it as one pill, then the total; or rows past a warning in amber and past an error in red | | Segmented | @danielbachhuber/bb-plugin-component-library/segmented | A row of choices, one always on, and a toggle form for a filter that can be off | | Calendar | @danielbachhuber/bb-plugin-component-library/calendar | A month grid, drawn the way bb draws one, for picking a day or a range | | Date range | @danielbachhuber/bb-plugin-component-library/date-range | A button that opens two months of calendar to pick a span of days | | Top bar | @danielbachhuber/bb-plugin-component-library/top-bar | A page's counts as a button in bb's top bar on every page, and the slot that puts it there, at the top left just after the sidebar toggle | | Start thread | @danielbachhuber/bb-plugin-component-library/start-thread | bb's new-thread composer under a row or in a dialog, with the plugin's suggested skills as chips, and the toast that links to the thread once it starts | | Thread status | @danielbachhuber/bb-plugin-component-library/thread-status | A row whose item has a thread: an orange edge, Open thread, and a line under the actions with what the thread is doing, read live from bb's sidebar |

sweep-ui, the list the three sweeps draw, is a separate package because only the sweeps use it.

Installing

It is published to npm as @danielbachhuber/bb-plugin-component-library, and a plugin depends on it as "@danielbachhuber/bb-plugin-component-library": "^1.0.0". In this checkout npm links the local component-library/ directory, since it is a workspace whose version fits that range, so an edit here reaches every plugin without publishing.

The published copy is what a marketplace install gets. bb installs a plugin from git with npm install --prefix <plugin dir> --omit=dev and builds it from source, so nothing is installed beside a file:../ sibling, and its imports of Hugeicons fail to resolve. A copy npm installs from the registry sits inside the plugin's own node_modules, where they resolve.

So a plugin release that needs a change made here needs that change published first. To find out whether it does, run this at the root:

node scripts/check-component-library.mjs <plugin>   # or no id, for every plugin

It prints STOP for a plugin that imports an entry the published version does not export, or one whose files here differ from the published tarball. The publish-plugin-update skill runs it before bumping a plugin's version. To publish:

cd component-library
npm version minor --no-git-tag-version   # patch, when no entry was added
npm publish

Raise the range in each plugin that needs the new version, run npm install at the root, and commit. Then run node scripts/check-installable.mjs <plugin> at the root, which installs the plugin from the last commit the way bb does.

React, react-dom, the Hugeicons packages, clsx, and tailwind-merge are peer dependencies, so the library uses the plugin's copy and React stays single. bb provides React, clsx, and tailwind-merge at runtime; the plugin lists the Hugeicons packages in its own dependencies.

react-day-picker is an optional peer, for the calendar. npm does not install an optional peer, so a plugin that imports neither Calendar nor Date range leaves it out of its lockfile. A plugin that imports either one lists react-day-picker in its own dependencies.

@get-bb/plugin-sdk and sonner are optional peers too, for Start thread and Thread status. bb provides both at runtime.

The package uses relative imports only, since @/ means the plugin's own directory once it is installed there. For the same reason it cannot use a plugin's vendored Button or Icon, and @get-bb/plugin-sdk/app exports neither, so an entry draws them itself with the same classes and Hugeicons glyphs.

bb.pluginTailwindContent in package.json lists the components for Tailwind. bb plugin build generates only the classes it finds in the plugin and in dependencies that list files this way. A bb block with no name does not make this a plugin: sync.sh and setup.sh look for bb.name.

An edit here does nothing in bb until every plugin that uses it is rebuilt. ./sync.sh at the repository root does that, since it counts this package as part of the source of every plugin that depends on it.

Sync status

import { SyncStatus } from "@danielbachhuber/bb-plugin-component-library/sync-status";

<SyncStatus syncedAt={listing?.syncedAt ?? null} busy={busy} onRefresh={refresh} />

It answers "is this page current?", so it shows how long ago the last sync landed ("synced 4m ago") rather than a clock time, which would leave the reader to do the subtraction.

Where it goes. The right end of the page's title bar, through the route's headerContent slot. Each plugin keeps a small SyncHeader in its app.tsx that subscribes to its own listing, calls its own refresh RPC, shows a failure with toast.error, and passes the result in. That wiring stays in the plugin because the RPC and realtime channel differ per plugin. Now, PR Sweep, Issue Sweep, Review Sweep, and Contributor Dashboard use it.

What it draws. A small muted label, then an outline Refresh button:

| State | Label | Button | | --- | --- | --- | | No sync has finished | not synced yet | Refresh | | Under a minute | synced just now | Refresh | | Under an hour | synced 4m ago | Refresh | | Under a day | synced 3h ago | Refresh | | A day or more | synced 2d ago | Refresh | | A refresh is running | unchanged | Refreshing…, disabled |

The label is lowercase because it sits mid-header. It re-reads the clock every 30 seconds, so it ages between syncs instead of sitting on "just now" until the next one.

What the syncs cost. Given usage, the label becomes a button that opens a summary under it, kept inside the window: the GitHub points the past hour of syncs used, one bar per sync placed at the time it ran (hover one for its points, calls, and time), and how many points the account has left before the hour resets. A sync that could not be measured draws as a faint stub and is left out of the total, which says how many it left out. A plugin calling several services, as Now does, gives each sync its calls by service and lists the calls made between syncs; the summary then counts calls rather than points, stacks each bar by service with a legend of the hour's totals, and keeps GitHub's points beside GitHub's calls. Without usage, the label stays plain text. The shape is what createSyncUsage in gh-shared reports, repeated here as SyncUsage because this package cannot depend on gh-shared. PR Sweep, Issue Sweep, Review Sweep, and Now pass it.

Props. syncedAt is the last sync's time in milliseconds, or null. busy disables the button. onRefresh starts a refresh. usage is the past hour's syncs and the account's budget. now pins the clock, and defaultOpen and initialHovered draw the summary open; all three are for stories and tests only. syncedAgo(syncedAt, now) is exported too.

Don't show an absolute time, put the control in the page body, call the button anything but Refresh, or write another formatter for the same label.

Sidebar count

import { SidebarCount } from "@danielbachhuber/bb-plugin-component-library/sidebar-count";

<SidebarCount urgent={2} total={5} urgentLabel="2 need you" totalLabel="5 to review" />

It answers "how much is on me here?" from the sidebar, without opening the page. Pass it to the route as experimental_sidebarAccessory, in a small component that reads the same listing as the page, so completing or archiving a row lowers the count at once.

What it draws. The rows that need you most in a red circle, then every row in muted text. The circle is left out at zero, and nothing is drawn at a total of zero. Each number has a tooltip naming what it counts, such as "2 need you" and "5 to review".

A page that also knows what needs you soon passes soon and soonLabel, and that count goes in an amber circle before the total. When both counts are above zero, the red and amber join into one pill, red then amber, so they read as one group. Count a row in one of them only. Now passes the tasks due today.

Lining up. bb centers a lone count in a box at least 20px wide, so the total keeps that box. A row with a circle and a row without one then have their totals in the same column. A page with nothing urgent still draws its total through this component, with urgent={0}, for that reason.

Now, PR Sweep, Issue Sweep, and Review Sweep use it.

Don't draw a bare number in a <span>, which drifts out of line with the rows around it, or count rows the page would not show.

Warnings and errors. A page whose total means nothing, such as Tokenomics, where every thread counts, draws SidebarLevels instead:

import { SidebarLevels } from "@danielbachhuber/bb-plugin-component-library/sidebar-count";

<SidebarLevels warning={3} error={1} warningLabel="3 past the warning" errorLabel="1 past the limit" />

Rows past the warning go in an amber circle, then rows past the error in a red one, the same red as SidebarCount's urgent circle. A row counts in one circle only, the higher. Each circle is left out at zero, and nothing is drawn when both are. Tokenomics uses it.

Segmented

import { Segmented, SegmentedToggle } from "@danielbachhuber/bb-plugin-component-library/segmented";

<Segmented label="Period" options={periods} value={period} onChange={setPeriod} />
<SegmentedToggle label="Source" options={sources} value={source} onChange={setSource} />

A row of a few short choices in one bordered group, drawn like bb's own segmented controls.

Which one. Segmented is for a choice that always has an answer, such as the period a page charts. It is a radiogroup, and one option is always on. SegmentedToggle is for a filter that can also be off: pressing an option shows only it, and pressing it again passes null and shows everything. It is a group of aria-pressed buttons.

Options. Each has an id and a label, an optional count drawn muted after the label, and an optional title for a tooltip that says what the option holds. label names the whole group for assistive technology.

Now, Tokenomics, and Contributor Dashboard use it.

Don't use it for more than about five options, or for options whose labels need more than a word or two. A select fits those better.

Calendar

import { Calendar } from "@danielbachhuber/bb-plugin-component-library/calendar";

<Calendar mode="range" numberOfMonths={2} selected={range} onSelect={setRange} />

A month grid over react-day-picker, with bb's own class names so it looks like the calendar in bb rather than like a plugin's own. bb's copy lives in packages/shared-ui in the bb repository, which is private to it, and the plugin SDK exports no calendar, so this is the nearest a plugin can get.

Props are react-day-picker's: mode, selected, onSelect, numberOfMonths, disabled, defaultMonth. It takes the component's whole API rather than wrapping a smaller one, because what a page needs from a calendar varies more than a wrapper could guess.

Don't reach for it directly to pick a span of days: Date range already draws the button, the panel, and Apply around it.

Date range

import { DateRange, type DayRange } from "@danielbachhuber/bb-plugin-component-library/date-range";

<DateRange value={range} onChange={setRange} earliest={twoYearsAgo} latest={Date.now()} />

A button that reads Sep 19 to Oct 9, or a placeholder when nothing is picked, opening two months of calendar with Cancel and Apply. It closes on Escape, on a click outside, and on Apply. earliest and latest grey out the days there is nothing to show for.

Whole days. value and onChange use { from, to } in epoch milliseconds, where from is midnight on the first day and to is midnight on the day after the last. A caller can hand to straight to a range query without adding a day to it, and the button still names the last day someone actually chose. dayRangeLabel is exported for a caller that wants the same wording elsewhere.

Contributor Dashboard uses it, beside the presets rather than instead of them.

Don't use it as the only way to choose a span. Picking two dates is slower than pressing a button, so offer the spans people ask for most as presets and keep this for the rest.

Day menu

import { DayMenu, describeDay } from "@danielbachhuber/bb-plugin-component-library/day-menu";

<PopoverContent align="start" className="w-72 p-1">
  <DayMenu
    heading="Snooze until"
    choices={[{ label: "Tomorrow", day: "2026-10-17" }]}
    describe={(day) => describeDay(day, now)}
    check={(day) => (day > today ? null : "Too early")}
    onPick={snoozeUntil}
    now={now}
    menuLabel="Snooze until"
    inputLabel="Snooze until a day"
  />
</PopoverContent>

What goes inside a "pick a later day" popover, as Now's Postpone first drew it: a line saying what moves, a few quick days each with the day in words beside it, a box for a day typed in words ("fri", "next mon", "oct 8", "in 3 days", "9/30", "2026-09-30") that says which day it read or why it cannot use it, and an optional note under a rule. Enter in the box picks the typed day. Days are YYYY-MM-DD strings throughout.

The popover stays in the plugin. bb's popover is vendored into each plugin, so the plugin draws its own button and PopoverContent and puts DayMenu inside. Radix unmounts the content when it closes, so the box is empty each time it opens.

The parser is separate. @danielbachhuber/bb-plugin-component-library/parse-day exports parseDay, describeDay ("Tomorrow", "Friday", "Oct 24"), localDay, addDaysTo, and nextMonday, with no React, so a server or a CLI can read a typed day the same way. day-menu re-exports them.

Now's Postpone and Review Sweep's Snooze use it.

Top bar

import { TopBarCount, TopBarSlot } from "@danielbachhuber/bb-plugin-component-library/top-bar";

<TopBarSlot pluginId="now" order={0} activeOn={["/plugins/now/now"]}>
  <TopBarCount icon={<Icon name="Target" />} label="Open Now" onClick={open}
    urgent={2} soon={3} total={9} urgentLabel="2 urgent" soonLabel="3 due today" totalLabel="9 in Now" />
</TopBarSlot>

It answers "how much is on me there?" from whatever page is open, and gets you to that page in one click. Mount it from a component registered with experimental_appOverlay, which reads the same listing as the page's sidebar count. That slot needs @get-bb/plugin-sdk 0.5.9 or later.

What it draws. TopBarCount is a borderless button, like bb's other header buttons, holding the page's icon and then exactly what Sidebar count draws: urgent in red, due soon in amber, joined into one pill when both show, then the total. At a total of zero only the icon shows, so the way to the page stays. On the page it opens, the button is shaded and marked aria-current="page", the way bb marks the open page in its own navigation.

Where it goes. bb has no plugin slot in its top bar, so TopBarSlot finds a place in the page instead: in the desktop app's title bar ([data-testid="app-window-title-bar"]), just after the sidebar toggle ([data-sidebar="trigger"]). In a browser, which has no title bar, it goes at the start of the page header's row ([data-testid="app-page-header-content-row"]), before the page title; a split thread has two header rows, and it takes the first, the leftmost pane's. On a page with neither, such as the new-thread page in a browser, it goes just before the right-panel toggle, found by its "Show right panel" or "Hide right panel" label, or else at the start of the header's buttons ([data-app-page-header-actions]). Several plugins' slots line up side by side, lowest order on the left and plugin id breaking a tie; each works the order out the same way, so they settle rather than moving past each other. It watches the document so it follows bb from page to page, and tells the button it is current under a path in activeOn. If a bb update changes all four, it draws nothing rather than throwing.

pluginId matters beyond naming the element: bb scopes a plugin's stylesheet to elements under data-bb-plugin="<id>", and the slot sits in bb's own header, outside the plugin's root. Without it the pill's colors are missing.

Now, Issue Sweep, Review Sweep, and PR Sweep use it, with order 0 to 3 in that order.

Don't put more than one button in the slot, or anything wider than the counts. The top bar holds a thread's own controls, and every plugin that adds a chip takes room from them.

Start thread

import { useStartThreadBox } from "@danielbachhuber/bb-plugin-component-library/start-thread";

const box = useStartThreadBox({
  navigate,
  submit: async (draft, request) => {
    const { threadId, error } = await rpc.call("start", { id: draft.key, request });
    return { threadId, error };
  },
  announcement: (draft) => `Started a thread for ${draft.key}`,
});

// The row's Start thread button:
onClick={() => box.toggle({ key, seed })}
// Under the row's action line:
{box.boxFor(key)}
{box.isStarting(key) ? <StartingThreadLine title={title} /> : null}

It is how a plugin starts a thread about one row: a pull request, an issue, a task, a plugin. It holds bb's own experimental_NewThreadComposer, so @-mentions, attachments, and the provider, project, environment, branch and permission pickers are bb's. The plugin supplies a seed and passes the resolved NewThreadRequest on to threads.spawn.

Where it opens. StartThreadInline draws it in a box under the row, where sweep-ui's Add note opens its editor, so the list stays in view. The row's Start thread button opens it, stays pressed (aria-expanded) while it is open, and closes it. sweep-ui's SweepList draws it from renderStartThread. StartThreadDialog draws the same thing in a dialog, under a card naming the row (seed.preview), for a page without rows to open it under. StartThreadForm is the contents of either.

Suggested skills. seed.skills lists the skills the plugin suggests, each with a reason shown on hover and an instruction, the line it puts in the prompt. They are drawn as chips above the composer, and the ones marked selected start picked. The prompt holds only the picked instructions, in the order the plugin listed them. bb's composer cannot have text inserted once it is open and will not submit an empty prompt, so a chip remounts it seeded with the new instructions, and anything typed before is dropped. Without skills, the composer is seeded with seed.prompt.

The rest of the seed. Whichever of projectId, providerId, model, permissionMode and environment the plugin has an opinion on. With seed.workspace.branch, a banner names the branch the thread runs on and the composer's project, environment and branch pickers are hidden, which needs FIXED_ENVIRONMENT_CSS on the page from a content script. The draft is saved per row and per seeded prompt, so an edit survives reopening the same row with the same chips and is dropped once the plugin seeds something different.

While it starts. useStartThreadBox holds which row's box is open and which rows' threads are starting. Pressing Send hides the box at once, and the row shows OpenThreadAction disabled and StartingThreadLine from Thread status until submit resolves, so resolve it once the row's own data lists the thread, such as after a reload. Where the data arrives later, startedThread(key) has the id meanwhile. The box is hidden rather than unmounted, so if no thread starts, it shows again with bb's composer having put the prompt back, and the reason at the top (StartThreadError). Two surfaces that draw the same rows, such as Now's list and its Email tab, pass the same scope, so a row started from either says Starting in both. Each keeps its own box.

After it starts. The hook calls announceStartedThread rather than navigate.toThread. Every plugin leaves you on the page you started the thread from, with a toast whose Go to thread button opens it. Opening a thread that already exists, from a row's Open thread, still navigates.

The dialog parts. The library cannot import a plugin's vendored components/ui/dialog.tsx, since @/ means the plugin's directory, so the plugin passes its own in to StartThreadDialog as dialog.

The three sweeps, Now, and Contributor Dashboard open it under the row. Impromptu UI and Plugin Shelf use the dialog. New issue, Presentations, and Weekly Review use only the toast.

Don't put the row's identity in the editable prompt instead of the card. Editing it could only break the link between the thread and its row.

Thread status

How a row looks once its item has a thread, the same in Now, the three sweeps, and Contributor Dashboard: an orange left edge, Open thread first in the action line where Start thread was, and a line under the actions, set off by an orange rule, saying what the thread is doing and its title.

const threadStatus = useThreadStatus(); // once, on the page

<li className={cn("py-3", threadId && THREAD_EDGE)}>
  …
  <OpenThreadAction onOpen={() => navigate.toThread(threadId)} />
  …
  <ThreadLine status={threadStatus(threadId)} onOpen={() => navigate.toThread(threadId)} />
</li>

useThreadStatus reads bb's sidebar, which the page already holds, so it makes no request. The line says Working, Needs you, Done, unread, Failed, unread, or Idle, as the sidebar's glyph would, and only Needs you and Failed are bold. THREAD_EDGE reaches into the list's padding the way a stale row's red edge does; THREAD_EDGE_INSET is for a list drawn inside its own border. A sweep passes the line to sweep-ui's row as threadLine, which draws it between the action line and the note.

Hovering Open thread underlines its label, and hovering the line underlines the thread's title.

While the thread is being started, draw <OpenThreadAction disabled /> and <StartingThreadLine title={…} /> in the same places: a spinner, Starting, and the title the row is known by, such as Let widgets export to CSV (#487). Start thread's useStartThreadBox says when.

Call the hook in the page and pass the lookup down, so a story can pass fixtures instead.

Don't let an overdue or due-today edge give way to the orange one. They say more.

Working on it

npm install
npx tsc --noEmit -p tsconfig.json
npm test

Run npm run storybook at the repository root to see the stories.