@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-uiConsume
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/toastregisters a custom element and touchesdocument, so import it from a client entry point or a dynamicimport()inside a browser-only lifecycle (useEffect,onMounted, anafterRenderhook) — 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-selectregisters a custom element and touchesdocument, so import it from a client entry point or a dynamicimport()inside a browser-only lifecycle (useEffect,onMounted, anafterRenderhook) — 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 × 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 --forceWith 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 --forceDo 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 declarationsEach 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.
