@bsuite/ui
Shared UI primitives for the BSuite apps (D2C Neon Electric + Corporate brands).
Ships as an npm package because Vercel deploys each BSuite app from its own
GitHub repo — the parent monorepo's packages/ directory does NOT exist in
the Vercel build context. Consumers install from npm with a pinned semver.
Components
Core primitives (v0.4.0+)
The package now owns the shared Tier 1 UI primitives apps should converge on:
import {
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
EmptyState,
ErrorBoundary,
LoadingSpinner,
StatusBadge,
} from '@bsuite/ui'
These primitives use BSuite/shadcn semantic token classes only (bg-primary,
text-muted-foreground, border-border, text-status-success, etc.) so D2C apps can
skin them through @bsuite/theme and Braden can map the same roles to the
corporate palette.
Storybook
Run the shared primitive gallery from this package:
pnpm -C packages/ui storybook
pnpm -C packages/ui build-storybook
DotPattern
Full-viewport dot-pattern background. SVG <pattern> tiling — ~1 DOM node
(vs ~1,800 motion.circle nodes on a typical viewport).
import { DotPattern } from '@bsuite/ui'
<div className="relative">
<DotPattern
width={28}
height={28}
cr={1.5}
className="opacity-[0.35] text-muted-foreground"
/>
{/* page content */}
</div>
Colour control via semantic Tailwind text-* utilities (dots use
fill="currentColor").
Logo
Slot-aware, theme-aware, branding-aware logo component for all BSuite apps.
import { Logo } from '@bsuite/ui'
<Logo slot="header" colorScheme="auto" />
D2CDefaultLogo
Inline D2C Neon Electric SVG logo (no asset dependency).
import { D2CDefaultLogo } from '@bsuite/ui'
<D2CDefaultLogo className="h-8 w-auto" />
Branding Components (v0.3.0+)
OklchColorPicker
A color input component for OKLCH color format (BSuite standard).
import { OklchColorPicker } from '@bsuite/ui'
<OklchColorPicker
value="oklch(0.546 0.215 262.9)"
onChange={(value) => console.log(value)}
label="Primary Color"
description="Your brand's primary color in OKLCH format"
/>
Features:
- Live color preview
- Input fields for L (lightness), C (chroma), H (hue)
- Slider controls for easy adjustment
- Validation of OKLCH format
- Accessible labels and error messages
ColorEditorSheet
A sheet/dialog for editing branding colors with a slot-based color editor.
import { ColorEditorSheet, OklchColorPicker } from '@bsuite/ui'
<ColorEditorSheet
open={isOpen}
onOpenChange={setIsOpen}
title="Edit Primary Color"
description="Adjust the OKLCH color values"
value="oklch(0.546 0.215 262.9)"
onSave={(value) => updateBranding('primary', value)}
renderEditor={(props) => <OklchColorPicker {...props} />}
/>
Features:
- Slide-out sheet interface
- Slot-based color editor (inject your own picker)
- Save/Cancel actions
- Responsive design
- Accessible dialog implementation
BrandingCard
A card component for displaying and editing tenant branding configuration.
import { BrandingCard, OklchColorPicker } from '@bsuite/ui'
<BrandingCard
branding={{
primary: "oklch(0.546 0.215 262.9)",
accent: "oklch(0.769 0.132 191.7)",
logo_url: "https://example.com/logo.svg",
company_name: "Acme Corp"
}}
onUpdate={async (key, value) => {
await updateTenantBranding(key, value)
}}
renderColorEditor={(props) => <OklchColorPicker {...props} />}
/>
Features:
- Display current branding values (colors, logos, company name)
- Edit buttons for each branding property
- Integrated color editing with ColorEditorSheet
- Image preview for logos
- Accessible and responsive design
- Support for light/dark mode logo variants
Branding Properties:
primary- Primary action color (OKLCH)accent- Accent/secondary color (OKLCH)logo_url- Main logo URLlogo_light_url- Light mode logo variant (optional)logo_dark_url- Dark mode logo variant (optional)favicon_url- Favicon URL (16x16 or 32x32)company_name- Company name for alt text
EntitySelector (v1.2.0+)
Generic, schema-agnostic searchable combobox for picking a row out of any Supabase table. Extracted from crm7 (AD-5, 2026-08-17) — crm7 had 65 files of adoption and two other apps had each started hand-porting their own copy at the identical path. This is now the sole implementation.
Data access is dependency-injected — the package does not own a Supabase
client, since every app's client is shaped differently (Vite-SPA singleton,
per-request createClient() factory, distinct storageKey, …):
import { EntitySelector } from '@bsuite/ui'
import { supabase } from '@/lib/supabase' // your app's own client
<EntitySelector<Employer>
supabaseClient={supabase}
table="employers"
value={form.employer_id}
onSelect={(emp) => setValue('employer_id', emp?.id ?? null)}
displayField={(r) => r.business_name}
secondaryField={(r) => r.abn ?? r.industry}
searchColumns={['business_name', 'trading_name', 'abn']}
/>
Recommended per-app pattern: wrap the import once in a thin local file
that binds your app's client, so the rest of the app's ~dozens of call sites
never repeat supabaseClient={supabase}:
// src/components/entity/EntitySelector.tsx
import { EntitySelector as BaseEntitySelector, type EntitySelectorProps as BaseProps } from '@bsuite/ui'
import { supabase } from '@/lib/supabase'
export type EntitySelectorProps<T extends Record<string, unknown>> = Omit<BaseProps<T>, 'supabaseClient'>
export function EntitySelector<T extends Record<string, unknown>>(props: EntitySelectorProps<T>) {
return <BaseEntitySelector<T> supabaseClient={supabase} {...props} />
}
Features: debounced typeahead (ilike across configured columns),
optional cross-schema reads (schema prop), a filterFn escape hatch for
arbitrary PostgREST filter chains, "one-shot" suggested options (rows already
linked on an earlier screen, shown before any typing), an async-safe
onSelect (no optimistic UI when the caller's select routes through an
import/RPC step), and a distinct error state (not the same "no results" copy
a genuine empty search shows).
Also exported for building custom pickers on the same visual language:
Command, CommandInput, CommandList, CommandGroup, CommandItem,
CommandEmpty, Popover, PopoverTrigger, PopoverContent.
Utils
cn(...classes)
shadcn-style className merger — clsx + tailwind-merge. Available for
consumers that want to compose @bsuite/ui components with local overrides
without pulling shadcn again.
Module Exports
The package provides subpath exports for better tree-shaking:
// Main exports
import { Logo, BrandingCard, Button, StatusBadge } from '@bsuite/ui'
// Dot pattern only
import { DotPattern } from '@bsuite/ui/dot-pattern'
// Utils only
import { cn } from '@bsuite/ui/utils'
// Branding components only
import { BrandingCard, ColorEditorSheet, OklchColorPicker } from '@bsuite/ui/branding'
// Primitive subpaths
import { Button } from '@bsuite/ui/button'
import { DialogContent } from '@bsuite/ui/dialog'
import { EmptyState } from '@bsuite/ui/empty-state'
import { EntitySelector } from '@bsuite/ui/entity-selector'
import { Command, CommandInput } from '@bsuite/ui/command'
import { Popover, PopoverContent } from '@bsuite/ui/popover'
// SVG asset
import logoSvg from '@bsuite/ui/assets/d2c-default-logo.svg'
Releases
- Edit source under
src/. pnpm -C packages/ui test— all cases must pass.pnpm -C packages/ui build— emitsdist/.- Bump version in
package.json(semver). - Merge the package release PR to
main;.github/workflows/publish-ui.ymlpublishes via npm Trusted Publishers (GitHub Actions OIDC, noNPM_TOKEN). - Update consumer
package.jsonto the new version, regenerate lockfile outside the bsuite tree (see rootCLAUDE.mdrule 7), commit the lockfile + a consumer-side bump PR.
NEVER use workspace:* or file:../packages/ui for this package in a
consumer — Vercel builds can't resolve those.
Release 1.3.4
Finalises the 1.3.4-next.2 source without a further runtime change. That preview
already rejects resource-loading image functions in every stylesheet context
and includes the accessible selector clear control below. Consumer adoption of the exact
stable version follows publication and a check of existing stored branding CSS.
Custom branding CSS boundary
Resource-loading image functions are rejected whole-stylesheet, after escape decoding,
case folding and vendor-prefix removal, in every context (rules, @media, @supports
preludes, keyframes, @property and custom-property values, var() fallbacks):
url, src, image, image-set, cross-fade, element and paint. Pure paint
images such as linear-gradient, radial-gradient, conic-gradient and their
repeating forms remain accepted, subject to the semantic-colour rule below. Stored
tenant CSS that uses these functions now fails validation; measuring that stored
data is a consumer-adoption step, not part of this package change.
validateCustomCss is exported from both @bsuite/ui and @bsuite/ui/branding.
Call it before saving a stylesheet. An accepted result contains the unchanged
stylesheet; an invalid or unsupported result contains an actionable reason and,
when available, the source line. Keep the author's draft on rejection.
sanitizeCustomCss retains its existing string API for every runtime sink and
returns an empty string for a rejected stylesheet. Insert accepted CSS through
HTMLStyleElement.textContent, never HTML interpolation.
The owning parser is CSS-tree 3.2.1. Its AST and property/descriptor grammar allow layout, typography, media/supports/container/layer rules, keyframes and bounded local custom properties. Colour consumers must use semantic theme variables; transparent and currentColor remain neutral values. Raw palette colours, endpoint colours including alpha forms, unsafe resources, protected role and shadcn definitions, and unsupported syntax reject the whole stylesheet. Escapes, variable fallbacks, alias cascades and typed custom-property initial values pass through the same boundary. Parsed @supports capability probes do not paint or redefine roles; their real rule blocks remain guarded, and resource/executable exclusions still apply throughout the stylesheet. Custom animation and grid names retain their case and are not mistaken for colour bindings. The parser and alias expansion have explicit source, token, depth and node limits.
Theme palette namespaces and their colour aliases are also protected, because theme roles consume them outside the submitted stylesheet. Tailwind colour and whole paint/effect slots (including gradients, shadows, rings and masks) cannot be redefined. Its numeric transform, position and mask geometry slots remain authorable, with colour-bearing aliases and fallbacks checked. Tests derive this boundary from both theme sources and actual emitted Tailwind utilities; this does not claim to identify arbitrary variables in unrelated external stylesheets.
Editors must surface rejection rather than silently saving empty CSS. Runtime validation is also required because existing persisted CSS predates this rule. BSU, CRM7 and Braden are the three custom-CSS consumers in the current six-app source census; published-package adoption and deployed user acceptance remain separate gates.
EntitySelector exposes an optional native sibling clear button for selected
entities. Its accessible name includes the selected label. Click, Enter and
Space call onSelect(null) once and return focus to the combobox without opening
the picker or submitting a surrounding form. The disabled and clearable=false
contracts apply to this button. Promise-returning parent saves remain authoritative:
a pending clear prevents resubmission and conflicting selection/creation,
rejection retains the saved selection and
shows a retry message, and successful clearing follows the controlled value. Settlement returns focus only
when it remains on the initiating control or was lost through disabling it;
focus deliberately moved to another field is preserved.
Selected-ID hydration and filtering are separate
contracts and are unchanged by this repair.