Skip to Content

UI Package

The UI package (@sustentus/ui) is a shared component library built with React, TypeScript, and Tailwind CSS, providing 45+ production-ready components.

Overview

Package Name: @sustentus/ui Path: packages/ui/ Build Tool: TSUP Version: 1.0.0

Purpose

Provides reusable UI components for all Sustentus applications, ensuring:

  • Consistency: Unified design language across apps
  • Reusability: Write once, use everywhere
  • Maintainability: Single source of truth for components
  • Accessibility: ARIA-compliant components
  • Type Safety: Full TypeScript support

Technology Stack

Core

  • React 19: UI library
  • TypeScript 5: Type safety
  • Radix UI: Accessible component primitives
  • Tailwind CSS v4: Utility-first styling

Build

  • TSUP: TypeScript bundler powered by esbuild
  • Tailwind CLI: CSS compilation
  • Concurrently: Run multiple build processes

UI Libraries

  • class-variance-authority: Variant-based component styling
  • tailwind-merge: Intelligent class merging
  • Lucide React: Icon library

Form Libraries

  • React Hook Form: Form state management — Form wraps it
  • Zod: Not imported by the package; declared so the ai types resolve against the catalog version the apps use. Apps that validate forms declare zod and @hookform/resolvers themselves

Additional

  • Sonner: Toast notifications
  • date-fns: Date utilities
  • React Day Picker: Date picker

Directory Structure

ui/ ├── src/ │ ├── base/ # shadcn/ui primitives (accordion, button, card, ... 35+) │ │ └── index.ts │ ├── components/ # Standalone components (rich-text-editor) │ │ └── index.ts │ ├── compound/ # Composed components │ │ ├── ai/ # AI chat and answer components │ │ ├── admin-table.tsx │ │ ├── list-pager.tsx │ │ ├── ... (date pickers, detail page shell, spotlight, skeletons) │ │ └── index.ts │ ├── hooks/ │ │ └── use-mobile.ts │ ├── lib/ │ │ ├── utils.ts # cn() and utility functions │ │ ├── variants.ts # Shared cva variants │ │ └── sustentus.otf # Brand font │ ├── globals.css # Global styles & Tailwind │ ├── index.ts # Client entry point ("use client" banner) │ └── server.ts # Server-safe entry point (no banner) ├── dist/ # Build output (wiped at the start of each build) │ ├── index.js / .d.ts # Client barrel │ ├── server.js / .d.ts # Server-safe barrel │ ├── lib/utils.js / .d.ts # cn() utility │ ├── fonts/ # Brand font │ └── globals.css # Compiled CSS ├── components.json # shadcn/ui config ├── package.json ├── tsconfig.json └── tsup.config.ts

Components

The package includes 40+ components organized by category:

Form Components

  • Button: Clickable button with variants
  • Input: Text input field
  • Textarea: Multi-line text input
  • Checkbox: Boolean selection
  • Select: Dropdown selection
  • Switch: Toggle switch
  • Label: Form field label
  • Form: Form wrapper with validation

Layout Components

  • Card: Content container with header/footer
  • DetailPageShell: Shared detail/sub-page header frame — back-link, accent icon, title, description, action slot, and content
  • Separator: Visual divider
  • Tabs: Tabbed interface
  • Accordion: Collapsible sections
  • Scroll Area: Custom scrollbars

Overlay Components

  • Dialog: Modal dialog
  • Sheet: Slide-out panel
  • Popover: Floating content
  • Tooltip: Hover hint
  • Hover Card: Rich hover content
  • Dropdown Menu: Contextual menu
  • Alert Dialog: Confirmation dialog

Feedback Components

  • Alert: Inline message
  • Toast: Notification (via Sonner)
  • Progress: Progress indicator
  • Skeleton: Loading placeholder
  • EmptyState: The zero state every surface renders when it has nothing to show — icon, title, optional description (omitted for a one-line state, as the figure pages’ FigureEmpty does), optional action, compact for popovers, and tone (EmptyStateTone: neutral by default, success for a good-news empty such as “Nothing is blocked”, drawn in the success subtle circle); ships from /server

Data Display

  • Table: Data table
  • AdminTable: Column-config admin table with its own pager
  • ListPager: Previous/Next footer for a custom list table, from the pagination envelope
  • Badge: Status indicator
  • Avatar: User avatar
  • Calendar: Date picker calendar

Input Components

  • Input OTP: One-time password input
  • Command: Command palette
  • Toggle: Toggle button
  • Toggle Group: Toggle button group
  • ThemeSwitch: The one System/Light/Dark control — segmented (settings panels, sidebar footers; tone="sidebar" on sidebar tokens) or menu (a header icon button)

Utilities

  • useIsMobile: True below the 768px mobile breakpoint (false until mounted)

AI Components

Compound components for building AI chat interfaces — used by the assistant in the agentic app, the onboarding concierge, and the conversation record on the platform’s Requirements section:

  • PromptInput: Controlled text input for submitting messages to the AI agent
  • Confirmation: Approval prompt for AI-generated outputs (e.g. BRD approval)
  • Image: Image rendering within chat messages
  • Shimmer: Skeleton loading state for streaming AI responses
  • Message: Chat message bubble with role-aware styling (user vs. assistant)
  • MessageAvatar: Identity slot on a message — the caller supplies the mark
  • MessageCopyButton: Copy a reply to the clipboard, with a visible confirmation
  • MessageTimestamp: Subtle <time> stamp on a message
  • MessagePending: In-flight affordance rendered inside the reply’s own bubble (or section), so streamed text replaces it in place
  • BoardHeading: A board’s h1 whose words may arrive after the page — a one-line slot reserved with a shimmer until they land, so nothing beneath it moves
  • MessageQuestion: A person’s turn on a board, as a large plain question — no bubble, no fill
  • MessageSection: One turn on a board at full width, set off by space and an optional hairline (divided) rather than a card. The agentic app renders with these three; the bubble primitives above remain for the concierge, the conversation record and the console’s prompt test chat
  • Suggestion / Suggestions: Conversation-starter chips that send their text as an ordinary turn
  • ConversationNotice: A status the conversation reports (unreachable model, failed turn), rendered in the transcript rather than as a banner above the composer

These render an agent’s typed tool output — never model-emitted markup — and render purely from props: every figure, share and label was computed server-side, so none of them derives a number of its own.

  • AnswerClaim: A figure the reader can check — the headline with the question it answers, its definition grid (population, period, basis date, measure), what is excluded, at most two caveats, the arithmetic that reaches the headline, and the records behind it. Says whether the definition came from the metric dictionary or from the answer itself, and shows the rate and rate date of a converted amount
  • GeneratedTable / GeneratedFunnel / GeneratedShares: A decomposition produced for the question that was asked and labelled as such — never dressed as a saved report. Bar widths are the shares the server distributed, so the bars and the printed percentages cannot disagree. A share split reads across rather than down — its rows are alternatives, not stages — and each carries a caption, because a share says how much and never how well
  • AnswerTableCell: One cell of a record set, plain or said in a tone — positive, caution, negative, info or neutral, rendered as a brand semantic pill with red reserved for the problem. The tone arrives with the value; nothing here classifies anything
  • AnswerReasoning: The register’s reading of the figure above it, as an ordered argument set apart in a dashed block — including the assumption it names as the one to attack first. Emphasis is a typed flag on a span, never markup, so nothing here needs sanitising
  • AnswerActions: One primary follow-up and up to three others, with a footnote. The label is the prompt — the handler receives the clicked text and nothing else fires — and the row disables as one while a turn is in flight
  • AnswerOptions: The questions one turn asked with clickable answers — a single choice (sent on the click when it is the turn’s only question) or a checklist, and several questions held behind one Continue so a turn is answered by one message. The handler receives the picks and nothing else fires; once answered, the groups render disabled with the picks marked
  • AnswerMeter: One figure against the whole it is part of. The bar’s width is the percentage the server sent; the component computes none of its own
  • AnswerJourney: A subject’s position on a workflow journey, with drop-offs beside the spine rather than on it. Holds no status list — the spine, the labels and the position all arrive as props from the workflow engine
  • AnswerLevers: What the reader could do on the subject, and where each of those is done — grouped, each row an intent dot, a hint and a link out to the platform page that owns the action. Holds no catalogue and no handler: every lever arrives as a prop, derived server-side from the platform’s own lever catalogue intersected with the caller’s permissions, and nothing on the rail fires
  • TraceRail: What ran behind an answer — each tool with the organisation it was bound to, the scope it actually ran at, its arguments, outcome and step timing. Collapsed by default, and never fabricating: “no tools ran” and “the run record could not be read” are different states

Usage

Installation

In an application:

pnpm add @sustentus/ui@workspace:*

Importing Components

import { Button, Card, Input } from "@sustentus/ui"; const MyComponent = () => ( <Card> <Input placeholder="Enter text" /> <Button>Submit</Button> </Card> );

The root barrel ships with a "use client" banner, so a component that takes a function prop cannot come from it — a client reference cannot receive a function across the server/client boundary. Those components are purely presentational and ship banner-free from /server instead, which is the only import path for them. EmptyState is one of them: its icon prop takes the lucide component itself, so the server pages that render most empty states can pass it:

import { AdminTable, EmptyState, ListPager } from "@sustentus/ui/server";

Client components may import from /server too — the absent directive is permissive, not restrictive.

The package declares only its CSS files as side effects ("sideEffects": ["*.css"] in its package.json), so a bundler may leave the unused exports of either barrel out of a page’s client bundle — a client component that imports EmptyState from /server does not carry AdminTable with it. A module added to packages/ui must therefore do nothing at import time beyond defining its exports.

AdminTable and ListPager render every link they draw — the pager’s Previous/Next and AdminTable’s row links — through an optional linkComponent, a plain <a> by default, so the package carries no next dependency. App code does not import them from /server directly: each Next app renders them through a wrapper that binds next/link, so paging and opening a row are soft navigations, and ESLint rejects the direct import there.

// apps/web import { AdminTable } from "@/components/ui/admin-table"; import { ListPager } from "@/components/ui/list-pager"; // apps/console import { ListPager } from "@/components/list-pager";

Copy belongs to the caller

Shared components carry no default copy a caller could inherit by forgetting it. PromptInputTextarea’s placeholder and RichTextEditor’s placeholder are required: each surface says what to write. AdminTable’s emptyMessage has no default either, and is optional: a list that returns its own empty state before rendering the table (every apps/web list does, through ListEmpty) passes none, and the table renders nothing for zero rows. The strings the package does ship — accessible names, dialog titles, the “Working…” pending label on MessagePending and BoardHeading — follow the copy guide: sentence case, and ”…” rather than ”…”.

Importing Styles

Import global CSS in your app layout:

import "@sustentus/ui/globals.css";

Using the cn Utility

import { cn } from "@sustentus/ui/utils"; const className = cn( "base-class", condition && "conditional-class", anotherClass, );

Component Examples

Button

import { Button } from "@sustentus/ui"; // Default button <Button>Click me</Button> // Variant buttons <Button variant="destructive">Delete</Button> <Button variant="outline">Cancel</Button> <Button variant="ghost">Ghost</Button> <Button variant="link">Link</Button> // Sizes <Button size="sm">Small</Button> <Button size="lg">Large</Button> <Button size="icon"><Icon /></Button>

Variant names

One name per intent, one look per name — packages/ui/src/lib/variants.ts holds them all:

ComponentVariants
Badgedefault · neutral · outline · success · warning · destructive · info
Buttondefault · destructive · outline · secondary · ghost · link · warning
Alertdefault · destructive · success · warning · info
Carddefault · muted · success · warning · destructive · info · highlighted
Progressdefault · success · warning · destructive · info
Toastsuccess · warning · destructive · info · default

“Bad” is destructive everywhere — there is no error or primary. Badge, Alert, Card and Toast destructive are the subtle tint (bg-destructive-subtle); Button destructive is the solid fill. Toast’s names style Sonner’s toast types — toast.error() renders the destructive look.

Every component in the table exports its variant names as a type from the package root — BadgeVariant, ButtonVariant, AlertVariant, CardVariant, ProgressVariant and ToastVariant. Each is declared once in packages/ui/src/lib/variants.ts as the keys of its config (keyof typeof cardVariantConfig), so it cannot drift from the variants the component styles. A caller’s own map, return type or prop of variants is typed with them (Record<Status, BadgeVariant>, or VariantSubset<ButtonVariant, "default" | "outline"> where only some are allowed), never a hand-typed union: renaming a variant then fails typecheck at every caller instead of a build after merge. VariantSubset (also from the package root) constrains its names to the variant type, so a misspelled or renamed name fails at the alias itself — Extract<…> would drop it silently and the error would surface only at a caller.

import { Badge, type BadgeVariant } from "@sustentus/ui"; const STATUS_VARIANT: Record<InvoiceStatus, BadgeVariant> = { paid: "success", overdue: "destructive", draft: "neutral", };

Typography takes sizes xs–5xl and weights normal · medium · semibold · bold, and renders at size="base" / weight="normal" whatever the element — a heading passes its size.

Form

import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage, Input, Button } from "@sustentus/ui"; const schema = z.object({ email: z.string().email(), }); const MyForm = () => { const form = useForm({ resolver: zodResolver(schema), }); return ( <Form {...form}> <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <FormControl> <Input {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <Button type="submit">Submit</Button> </Form> ); };

Development

Running in Watch Mode

# From root pnpm ui:dev # From ui directory cd packages/ui pnpm dev

This runs TSUP in watch mode (rebuilds on code changes) and Tailwind in watch mode (recompiles CSS).

Building

# From root pnpm ui:build # From ui directory cd packages/ui pnpm build

The build bundles TypeScript to JavaScript, generates declarations, and compiles CSS. pnpm clean removes the dist/ folder.

Configuration

TSUP Config

tsup.config.ts:

import { defineConfig } from "tsup"; // Three build units. The component barrel is client-only ("use client" banner); // lib/utils and the server barrel ship WITHOUT it, so server components can call // cn() and render the presentational components in src/server.ts directly. A // banner'd component can only receive serializable props, so anything that takes // a function prop (AdminTable's render/rowKey/rowHref) has to live banner-free. // // Every unit sets `clean: false` — they share an outDir and run concurrently, so // a clean here races their .d.ts emits and deletes whichever landed first (this // is why dist/lib/utils.d.ts used to go missing). The `build` script wipes dist // once up front instead. export default defineConfig([ { entry: ["src/index.ts"], format: ["esm"], dts: true, clean: false, sourcemap: true, minify: false, target: "es2022", outDir: "dist", external: ["react", "react-dom"], banner: { js: '"use client";', }, }, { entry: { "lib/utils": "src/lib/utils.ts" }, format: ["esm"], dts: true, clean: false, sourcemap: true, minify: false, target: "es2022", outDir: "dist", }, { entry: { server: "src/server.ts" }, format: ["esm"], dts: true, clean: false, sourcemap: true, minify: false, target: "es2022", outDir: "dist", external: ["react", "react-dom"], }, ]);

Every unit sets clean: false: the units share an outDir and run concurrently, so a clean would race their .d.ts emits and delete whichever landed first. The build script runs rm -rf dist once up front instead.

Package Exports

package.json:

{ "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" }, "./globals.css": "./dist/globals.css", "./server": { "import": "./dist/server.js", "types": "./dist/server.d.ts" }, "./utils": { "import": "./dist/lib/utils.js", "types": "./dist/lib/utils.d.ts" } } }

/server and /utils are built without the "use client" banner, so a server component can call cn() and render the components in src/server.ts directly.

React and React DOM are peer dependencies (^19); all other Radix UI, validation, and utility libraries are bundled.

Styling

Components use Tailwind utility classes and class-variance-authority for variants:

import { cva } from "class-variance-authority"; const buttonVariants = cva("inline-flex items-center justify-center", { variants: { variant: { default: "bg-primary text-primary-foreground", destructive: "bg-destructive text-destructive-foreground", }, size: { default: "h-10 px-4 py-2", sm: "h-9 px-3", lg: "h-11 px-8", }, }, defaultVariants: { variant: "default", size: "default", }, });

The cn utility merges classes intelligently:

import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));

Theme tokens

src/globals.css is the single token sheet every app imports. Colour never comes from a numeric palette step — those are declared once in the @theme block and are never redefined, so a surface painted with one keeps its light-mode colour in dark mode. The semantic tokens are the unnumbered names, each defined twice: under :root and again under .dark, then exposed to Tailwind through @theme inline.

Apps import the compiled sheet (@sustentus/ui/globals.css → dist/globals.css, built by the Tailwind CLI), so an app’s own Tailwind pass never sees this theme. A token utility (bg-success-subtle, bg-overlay, text-primary-600) exists only if a file in one of the trees src/globals.css lists with @source uses it: apps/marketing, apps/docs, apps/web, apps/console, apps/agent and packages/ui itself. apps/demo (frozen) is not listed, so its token classes are not generated. A new app that uses token classes needs its own @source line. The @theme block keeps only the numeric steps something references.

GroupTokens
Surfacesbackground · card · popover · muted · sidebar · overlay
Textforeground · muted-foreground and each surface’s -foreground pair
Lines and focusborder · input · ring
Brandprimary · secondary · accent
Intent, solid fillsuccess · warning · destructive · info + their -foreground
Intent, tinted surface<tone>-subtle · <tone>-subtle-foreground · <tone>-border
Categoricalchart-1 … chart-10

The modal backdrops (Dialog, Sheet, AlertDialog) paint bg-overlay: black at 50% in light, 60% in dark.

The solid intent tones are fills. For intent-coloured text or icons on a plain surface, use text-<tone>-subtle-foreground — the readable step of the same hue. --warning as text measures 2.81:1 on a card; --warning-subtle-foreground measures 7.81:1.

A -foreground has to be checked against its tone in both blocks. info and destructive resolve to their lighter 400 step in dark, so their dark foregrounds are near-black; a near-white one gives 2.42:1.

eslint-rules/no-fixed-palette-classes.mjs enforces all of this at "error" across apps/web, apps/console, apps/agent and packages/ui, at zero occurrences. The rule cannot see globals.css (ESLint does not lint CSS), which is correct — that file is where the scales are legitimately declared.

Accessibility

All components built on Radix UI primitives are keyboard navigable, screen-reader friendly (proper ARIA attributes), focus-managed, and WCAG compliant.

RichTextEditor is not a Radix primitive: it renders a TipTap contenteditable, which a <label htmlFor> cannot name. It forwards id, aria-label and aria-labelledby to the editable element, and every call site names it by the id of the visible label or heading beside it:

const labelId = useId(); <Label id={labelId}>Overview</Label> <RichTextEditor aria-labelledby={labelId} value={value} onChange={setValue} placeholder="Describe the engagement" />

Inside a react-hook-form FormControl, give the FormLabel the id and pass aria-labelledby the same way; the id that FormControl passes reaches the editable element.

Reduced motion

When the user’s system asks for reduced motion (prefers-reduced-motion: reduce), one block at the end of src/globals.css turns down every shared animation, so no component has to opt in:

AnimationUnder reduced motion
Enter/exit (animate-in / animate-out with zoom-*, slide-*, spin-*)Fade only, 150 ms at most
animate-spinKeeps turning, one turn per 2 s
animate-pulse (Skeleton), animate-bounce, animate-caret-blink, animate-accordion-*Stopped
ShimmerStatic text in muted-foreground

The rules are unlayered and match class substrings ([class*="animate-in"]), because the utilities are variant-prefixed (data-[state=open]:animate-in) and an unlayered rule beats every @layer utilities rule. Redefining a @keyframes there does not work: Tailwind emits its own after the file. A component that branches its markup on motion’s useReducedMotion() mismatches hydration — the hook is null on the server — so express reduced motion with motion-reduce: classes, as Shimmer does.

Adding New Components

Using shadcn/ui CLI

If the component is in shadcn/ui:

cd packages/ui npx shadcn@latest add [component-name]

Manual Addition

  1. Create the component file in src/components/
  2. Export it from src/index.ts
  3. Build the package
// src/components/new-component.tsx import { type ComponentProps } from "react"; import { cn } from "../lib/utils.js"; export const NewComponent = ({ className, ...props }: ComponentProps<"div">) => ( <div className={cn("base-styles", className)} {...props} /> );
// src/index.ts export * from "./components/new-component.js";

Learn More

Last updated on