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

@energycap/internal-ui

v2.0.0

Published

EnergyCAP internal UI — shared Tailwind v4 theme + component classes for internal tools. Framework-agnostic.

Downloads

299

Readme

@energycap/internal-ui

Shared Tailwind v4 theme + component classes for EnergyCAP internal tools. Framework-agnostic — works with any UI framework or server-rendered HTML; bring your own markup (Razor, React, plain HTML) and apply the classes. Components are built Tailwind-native (@apply), so sizing and spacing track Tailwind's scale and the build path requires Tailwind v4 — the prebuilt dist is standalone CSS. Ships two TypeScript-authored JS components (Toast and Multi-select, Lit web components with Lit bundled into each runtime artifact) plus their declarations.

Install

npm install @energycap/internal-ui

Consume

Tailwind v4 build (npm + PostCSS, Vite, etc.):

@import "tailwindcss";
@import "@energycap/internal-ui";

No build — link the precompiled bundle (reset + tokens + components, no utility layer):

<link rel="stylesheet" href="node_modules/@energycap/internal-ui/dist/internal-ui.css" />

JS component (Toast) for bundler/TypeScript consumers — import the typed package subpath. It exports showToast, dismissToast, ToastContainer, ToastOptions, ToastVariant, and ToastPosition:

import { showToast, type ToastOptions, type ToastPosition } from '@energycap/internal-ui/toast';

const options: ToastOptions = {
  variant: 'success',
  title: 'Saved',
  description: 'The record was saved.',
  duration: 5000,
};

// Only needed for a non-default position: keep the container you create, set the
// position, and append it — showToast() then reuses it instead of creating one.
const container = document.createElement('toast-container');
const position: ToastPosition = 'top-right';
container.position = position;
document.body.append(container);

showToast(options);

The public contract has four variants, six CSS-supported positions, a required title, optional description/icon classes/duration/dismissibility, and numeric toast IDs returned by showToast and ToastContainer.add.

The runtime is browser-only. Importing @energycap/internal-ui/toast registers a custom element and touches document, so import it from a client entry point or a dynamic import() inside a browser-only lifecycle (useEffect, onMounted, an afterRender hook) — not from module scope that runs during SSR. import type { ToastOptions } is erased at compile time and is safe anywhere, including in server-compiled files.

JS component (Toast) for no-build browser consumers — use the same self-contained ESM file directly. Lit is bundled in, so no separate install or bundler is required. Icon color is yours to define: the prebuilt CSS ships the --success / --warning / --danger / --info tokens but no Tailwind utility layer, so text-success and friends do not exist on a no-build page:

<style>
  .toast-icon-success { color: var(--success); }
  .toast-icon-warning { color: var(--warning); }
  .toast-icon-danger  { color: var(--danger); }
  .toast-icon-info    { color: var(--info); }
</style>

<script type="module">
  import { showToast } from '/node_modules/@energycap/internal-ui/dist/toast.js';
  showToast({
    variant: 'success',
    title: 'Saved',
    iconClasses: 'fa-solid fa-circle-check toast-icon-success',
  });
</script>

JS component (Multi-select) for bundler/TypeScript consumers — a typeahead field whose selection renders as removable chips. Import the typed package subpath; importing it registers <multi-select>:

import '@energycap/internal-ui/multi-select';
import type {
  MultiSelectChangeEvent,
  MultiSelectOption,
} from '@energycap/internal-ui/multi-select';

const field = document.querySelector('multi-select')!;

// Either a static list you already have…
field.options = [
  { value: '1', label: 'Duke Energy Progress' },
  { value: '2', label: 'Duke Energy Florida', disabled: true },
];

// …or a remote lookup. The fetcher owns the request, its headers and auth, and
// the mapping to options, so any response shape works. It wins over `options`.
field.fetcher = async (query, signal) => {
  const response = await fetch(`/api/vendors?term=${encodeURIComponent(query)}`, { signal });
  const rows: { id: number; name: string }[] = await response.json();
  return rows.map((row) => ({ value: String(row.id), label: row.name }));
};

field.addEventListener('multi-select-change', (event: MultiSelectChangeEvent) => {
  console.log(event.detail.values, event.detail.action);
});

The public contract: options, selected, and fetcher are properties only (arrays and functions can't come from attributes), so the option source is always configured from JavaScript; placeholder, input-id, name, disabled, min-query-length, debounce-ms, no-results-text, and loading-text are attribute-settable, so copy, sizing, and lookup tuning work from plain HTML. A fetcher takes precedence over a static options array. values is a read-only getter; write selected (which carries labels) to restore a selection. select(), deselect(), and clear() are the imperative API; assigning selected renders chips but emits nothing, since a programmatic set isn't a user change. Choosing an already-selected option is inert.

Lookups are debounced (debounce-ms) and suppressed below min-query-length. The superseded lookup is aborted through the signal handed to your fetcher, out-of-order responses are discarded, and a rejection clears the list to the no-results message — failures are never cached, so the next keystroke retries. Results render exactly as the fetcher returns them; the component does not re-filter them.

The event is multi-select-change (not change): the component renders a real <input> in light DOM, so a native change bubbling out of it would be indistinguishable. For that reason the inner input's native input and change are stopped at the host boundary.

It is form-associated: with a name, the selection contributes one FormData entry per value (name=a&name=b, as a native <select multiple> does) and form.reset() clears it. An ancestor <fieldset disabled> disables it too, and re-enabling the fieldset brings it back. Where ElementInternals is unavailable, form participation degrades and everything else still works.

Disabled state comes from either the disabled attribute or the form, and the component writes the combined result to data-disabled on the host — that's the hook to style against, since the form's half deliberately never becomes a disabled attribute (an own disabled attribute would stop the browser reporting the fieldset's state, latching the field inert). The shipped CSS already covers it.

The option list is promoted into the browser's top layer with popover="manual", so it escapes a scrolling or overflow: hidden ancestor — a modal body, a scrolling panel — rather than being clipped by it, which no z-index can fix. The component anchors the panel to the field and re-anchors it on scroll and resize, flipping above the field when there isn't room below. Where the Popover API is unavailable the panel falls back to absolute positioning against the field, which a scrolling ancestor does clip.

Because <label for> cannot target a custom element, use input-id to point a label at the inner input:

<label for="vendors">Related vendors</label>
<multi-select input-id="vendors" name="vendorIds" min-query-length="2"></multi-select>

The runtime is browser-only. Importing @energycap/internal-ui/multi-select registers a custom element and touches document, so import it from a client entry point or a dynamic import() inside a browser-only lifecycle (useEffect, onMounted, an afterRender hook) — not from module scope that runs during SSR. import type { … } is erased at compile time and is safe anywhere.

JS component (Multi-select) for no-build browser consumers — import the self-contained ESM file directly; Lit is bundled in:

<script type="module">
  import '/node_modules/@energycap/internal-ui/dist/multi-select.js';
  document.querySelector('multi-select').options = [{ value: '1', label: 'Alpha' }];
</script>

The prebuilt CSS ships every .multi-select-* and .chip class the component needs, but no Tailwind utility layer — so to cap the dropdown height or size the field, write your own rule against the tokens (multi-select .multi-select-listbox { max-height: 20rem; }) rather than reaching for a utility class like max-h-80, which doesn't exist on a no-build page.

Design tokens

All tokens are CSS variables on :root (so any CSS can use var(--…)), and the color tokens are also exposed as Tailwind utilities.

| Variable | Utility | Value | |----------|---------|-------| | --primary / --primary-hover | bg-primary / bg-primary-hover, text-primary | #162F3B / #0F2129 (dark EnergyCAP blue; white-text action color, AA) | | --accent | bg-accent, border-l-accent | #D8EA53 (lime; logo and decorative accent on dark surfaces; not a focus indicator on light backgrounds) | | --focus / --focus-soft | outline-focus, border-focus / — | #4275D8 / #DAE5FB (cobalt keyboard-focus indicator: outline or focus border / surrounding glow) | | --brand-gradient | — | lime → tan → purple linear-gradient — the 2026 brand sweep for hero moments (use dark --ink text on it) | | --hover / --selected | — | #EAECED / #DAE4E9 (neutral row/surface hover + selected fills) | | --chrome / --chrome-alt | bg-chrome / bg-chrome-alt | #FFFFFF / #F1F3F4 (LIGHT nav / app-frame surfaces — nav text is --ink) | | --canvas | bg-canvas | #F1F3F4 (page background) | | --surface / --surface-alt | bg-surface / bg-surface-alt | #FFFFFF / #EAECED | | --border / --border-strong | border-border / border-border-strong | #D6D6D7 / #ACBCC3 | | --ink / --ink-soft | text-ink / text-ink-soft | #1A1A23 / #354751 | | --muted / --muted-strong | text-muted / text-muted-strong | #5E6D79 / #495963 | | --on-dark / --on-dark-soft | text-on-dark / text-on-dark-soft | #F1F3F4 / #DAE4E9 (text on dark surfaces, e.g. the toast) | | --on-dark-muted / --on-dark-muted-strong | text-on-dark-muted / text-on-dark-muted-strong | #8C9CA5 / #ACBCC3 (dim/decorative / readable labels on dark) | | --success / --warning / --danger / --info | text-* / bg-* | #2B9A03 / #E8B604 / #DB0B30 / #2D5CB8 (Solaris status ramps, independent of the accent) | | --danger-hover | bg-danger-hover | #C50124 (solid danger button hover) | | --{success,warning,danger,info}-bg / -text | — | soft fill + readable (AA) text for alerts/badges | | --overlay | — | #1A1A23 @ 55% (modal backdrop scrim) | | --radius-sm/md/lg | rounded-sm/md/lg | 4 / 6 / 8 px (match Tailwind defaults) | | --shadow-card | — | card / panel elevation | | --shadow-overlay | — | modal / dialog elevation | | --font-sans / --font-mono | font-sans / font-mono | Inter / Cascadia Code |

Component classes

| Family | Classes | |--------|---------| | Buttons | .btn + .btn-primary / .btn-outline / .btn-danger, .btn-sm, .btn-icon-only (combine with .btn-sm for 24×24), .btn-group — 32px tall, 24px for .btn-sm | | Button toggle | .btn-toggle-group + .btn-toggle (radio = single-select, checkbox = multi), .btn-toggle-group-sm | | Forms | .form-group (> label), .form-control (input/select/textarea; supports readonly — muted, read-only — and disabled — inactive — states), .form-control-sm, .form-hint, .form-actions — 32px tall, 24px for .form-control-sm | | Cards | .card, .card-heading | | Tables | .table-container (+ .table-sticky) wrapping a <table>; .table-empty; .row-clickable + .row-anchor; .row-selected | | Badges | .badge + .badge-success/warning/danger/info/muted | | Chips | .chip (24px interactive token, matching .btn-sm) + .chip-label (truncates), .chip-remove (trailing &times; button; omit it and the padding goes symmetric), .chip-disabled | | Alerts | .alert + .alert-success/warning/error/info (.alert-danger alias) | | Layout | .app-shell, .sidebar (.sidebar-brand/-nav/-footer, a.active, .nav-section), .main-content, .content-header, .subtitle, .toolbar, .pagination | | Modal | .modal (panel) — on a native <dialog> (::backdrop scrim, recommended) or inside a .modal-overlay scrim (no-JS fallback); .modal-header + .modal-title (close button is a regular .btn .btn-outline .btn-icon-only); .modal-body; .modal-footer | | Toast | <toast-container> (Lit web component, light DOM) + showToast({variant, title, description?, iconClasses?, duration?, dismissible?}) from @energycap/internal-ui/toast; .toast for layout, with optional consumer-supplied icon classes. TypeScript declarations export ToastOptions, ToastVariant, ToastPosition, and ToastContainer. | | Multi-select | <multi-select> (Lit web component, light DOM, form-associated) from @energycap/internal-ui/multi-select — a chip typeahead field. Options from a fetcher (debounced remote lookup, wins) or a static options array; emits multi-select-change. Layout classes: .multi-select (host), .multi-select-control, .multi-select-field, .multi-select-input, .multi-select-actions, .multi-select-clear, .multi-select-divider, .multi-select-toggle, .multi-select-listbox, .multi-select-option (+ .multi-select-option-active), .multi-select-empty; the selection and the option rows use .chip. TypeScript declarations export MultiSelect, MultiSelectOption, MultiSelectFetcher, MultiSelectChangeAction, MultiSelectChangeDetail, and MultiSelectChangeEvent. |

See the kitchen-sink docs site (apps/docs) for live examples of every token and component.

Agent skills

For a private consuming app, keep the bundled agent guidance synchronized with the installed package by adding this script to the app's package.json:

{
  "private": true,
  "scripts": {
    "postinstall": "internal-ui install-skills --agent=all --force"
  }
}

Package scripts put dependency binaries on PATH, so the command works with npm, pnpm, Yarn, or Bun. --agent=all replaces both generated copies with the guidance bundled in the installed, lockfile-selected package version. Commit both directories so agents have the guidance before dependencies are installed and package upgrades produce reviewable changes:

  • .claude/skills/energycap-internal-ui — Claude Code and Cline.
  • .agents/skills/energycap-internal-ui — ChatGPT/Codex, Copilot, Pi, Cursor, Gemini, OpenCode, Roo, and Windsurf.

Some harnesses scan both shared roots and may report the identical skill twice. To avoid that, select only the harnesses the project uses; aliases that share a destination are deduplicated:

internal-ui install-skills --agent=claude --force
internal-ui install-skills --agent=codex --agent=pi --force

With no --agent, the backward-compatible default remains .claude/skills. Use --dest=<dir> for an unlisted harness or custom location. For a nested app package whose skills belong at the repository root, add --root=../.. (adjust the relative path from that package); agent directories resolve from that root.

If lifecycle scripts are disabled, refresh the skill explicitly from the app root:

npx @energycap/internal-ui install-skills --agent=all --force

Do not put this postinstall in a package published for other applications to consume: it would run in those downstream projects too.

Build

npm test                # CLI contract + type-checked interactive component suite
npm run test:types      # strict no-emit check for source and component tests
npm run test:components # type check, then Vitest + happy-dom component suite
npm run test:watch      # watch component tests
npm run build         # CSS, both bundled JS runtimes, and declarations
npm run build:css     # just the CSS
npm run build:debug   # CSS, unminified for inspection
npm run build:js      # both bundled JS runtimes (Toast, Multi-select)
npm run build:js:toast          # just one entry
npm run build:js:multi-select   # just the other
npm run build:types   # remove dist/types and emit strict declarations

Each JS component is built by its own vite build --mode <entry> invocation, so every published runtime stays a single self-contained file with Lit bundled in. A single multi-entry build would hoist the shared Lit into a dist/chunk-*.js that both artifacts import, which would break the no-build <script type="module"> path. npm run dev:js watches Multi-select; dev:js:toast watches Toast.

Changelog

See CHANGELOG.md for release notes and breaking changes to review before upgrading.