@ekanos/ui
Ekanos-styled UI primitives for building Ekanos integrations outside the
monorepo. This is the published companion to @ekanos/sdk: a curated slice of
the internal @kit/ui library, chosen from what the SDK's re-exported
components and the example integration packages actually import.
Docs: sdk.ekanos.dev/ui/overview.
Inside the monorepo, keep importing @kit/ui/* directly — this package exists
for partners who can't.
What's exported
One subpath per component, mirroring the @kit/ui names:
| Subpath | Contents |
|---|---|
@ekanos/ui/alert | Alert, AlertTitle, AlertDescription, AlertAction |
@ekanos/ui/button | Button, ButtonProps, buttonVariants |
@ekanos/ui/card | Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, … |
@ekanos/ui/badge | Badge, badgeVariants |
@ekanos/ui/status-badge | StatusBadge (neutral pill + tone dot), StatusTone |
@ekanos/ui/stat | Stat, StatLabel, StatFigure, StatValue, StatChange, StatDetail |
@ekanos/ui/page | PageBody, PageRail, PageTitleSection, PageToolbar, PageToolbarActions, PageRailTabs, PageRailSubTabs, PageBackButton, PageRangeSwitch, PageViewModeToggle |
@ekanos/ui/dialog | Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, … |
@ekanos/ui/separator | Separator |
@ekanos/ui/tooltip | Tooltip, TooltipTrigger, TooltipContent, TooltipProvider |
@ekanos/ui/select | Select, SelectTrigger, SelectValue, SelectContent, SelectItem, … |
@ekanos/ui/form | Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage, useFormField |
@ekanos/ui/input | Input |
@ekanos/ui/textarea | Textarea |
@ekanos/ui/switch | Switch |
@ekanos/ui/dropdown-menu | DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, … |
@ekanos/ui/label | Label |
@ekanos/ui/skeleton | Skeleton |
@ekanos/ui/icon | Icon, renderIcon, resolveIcon, IconStyleOverrideProvider, name helpers |
@ekanos/ui/utils | cn(), isRouteActive(), checkIfRouteIsActive() |
@ekanos/ui/if | If conditional renderer |
@ekanos/ui/trans | Trans (next-intl; needs a NextIntlClientProvider) |
@ekanos/ui/spinner | Spinner |
@ekanos/ui/ai-prompt-input | PromptInput compound components for AI ask surfaces |
@ekanos/ui/table | Table, TableHeader, TableBody, TableRow, TableHead, TableCell, … (static tabular facts) |
@ekanos/ui/data-table | DataTable, DataTableColumn and its sort/paging types (the house table: sorting, paging, row clicks) |
@ekanos/ui/empty-state | EmptyState, EmptyMedia, EmptyStateHeading, EmptyStateText, EmptyStateButton (a whole empty surface) |
@ekanos/ui/empty-note | EmptyNote (an empty card or section inside a populated surface) |
@ekanos/ui/tabs | Tabs, TabsList, TabsTrigger, TabsContent |
@ekanos/ui/sheet | Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, … |
@ekanos/ui/confirm-dialog | ConfirmDialog |
@ekanos/ui/search-field | SearchField |
@ekanos/ui/avatar | Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge, PersonAvatar (picture or initials from a name), initialsFromName |
@ekanos/ui/charts | Sparkline, ProgressRing, BarSeries (small hand-drawn charts for widgets, coloured by the --chart-* tokens) and their geometry helpers |
Most source files are a thin re-export of the internal @kit/ui library — for
those, @kit/ui stays the single source of truth. At build time that code is
inlined into dist/ so the published artifact has no workspace dependencies;
pnpm --filter @ekanos/ui pack:test fails the build if any @kit/ specifier
leaks into the tarball.
Five subpaths — if, trans, spinner, empty-state and data-table —
plus PageBody in page, are instead implemented natively here. Their internal counterparts are Makerkit-authored code from the
commercial Turbo starter this monorepo was built on; publishing that under this
package's MIT license would purport to grant reuse rights we do not hold. The
implementations shipped here are original, and pack:test fails the build if
any Makerkit-authored source is ever inlined into dist/.
Styling
Every component emits Tailwind class strings bound to Ekanos's semantic design
tokens (bg-primary, text-muted-foreground, border-border, shadow-ring,
…), so something has to define those. This package ships that CSS.
Pick one of the two paths. Loading both means two copies of every utility.
You do NOT use Tailwind — import the compiled stylesheet
import '@ekanos/ui/styles.css';
Self-contained: Tailwind's Preflight, the design tokens, light and dark, and
every utility class the components in this package emit — all pre-compiled.
Nothing else to install, no build step, no config. Works in Next.js, Vite, CRA,
a plain <link rel="stylesheet">, anything.
You DO use Tailwind v4 — import the preset
@import 'tailwindcss';
@import '@ekanos/ui/theme.css';
That registers the token map (--color-primary → --primary, the --radius-*
scale, shadow-ring), the token values for both themes, the base rules, the
dark: variant, and an @source pointing at this package's bundles so your
Tailwind build generates the components' utilities itself. You then get the
Ekanos tokens in your own class strings too.
Also published, if you want the pieces separately: @ekanos/ui/tokens.css
(just the custom properties, no Tailwind at-rules) and @ekanos/ui/base.css
(the base rules).
What styles.css does and does not contain
It contains the utilities this package's components emit — derived by compiling every class string found in the built bundles. It is not a copy of Tailwind.
So <Button> and <Card> render correctly, but a Tailwind class you write in
your component (p-8, grid-cols-3, h-5 w-5) has no rule in it and will do
nothing. That is inherent: a pre-compiled stylesheet cannot know class names
that don't exist yet. Two options, both fine:
- style your own markup with plain CSS or inline styles, or
- take the Tailwind path above, which is how Ekanos itself works.
styles.css includes Tailwind's Preflight, which is a global reset — it will
normalise margins, headings and form controls across your whole page. If that
is unwelcome, use the Tailwind path and control layering yourself.
Page background and dark mode
The stylesheet will not repaint your page uninvited. To adopt Ekanos's page surface — which is what makes dark mode look right rather than dark cards floating on a white page — add the opt-in class:
<body class="ekanos-root">
Dark mode resolves in three states, in this order:
| State | Result |
|---|---|
| nothing set | light |
@media (prefers-color-scheme: dark) | dark |
.dark or [data-theme='dark'] ancestor | dark, beating the media query |
.light or [data-theme='light'] on :root | light, beating the media query |
So a partner app with no theme toggle at all still follows the OS, and one with a toggle (next-themes, or your own) works by setting the class. Ekanos itself is class-only; the media query is added here for apps that have no switcher.
Tokens: generated from Ekanos's design system
tokens.css is generated from Ekanos's base theme
(the token stylesheet the Ekanos app itself uses), so it carries the full semantic set:
surfaces, brand, status with their -strong text steps, chart, sidebar,
the whole elevation scale and --radius. Only the
header and the partner font stacks are hand-written; everything below the
GENERATED marker is rewritten by:
pnpm --filter @ekanos/ui sync:tokens # after changing a token in the app
pnpm --filter @ekanos/ui sync:tokens --check # what CI's gate runs
The admin design-system page runs the sync for you when it saves. Never edit a
value in tokens.css directly: the next sync overwrites it, and until then the
build and the package tests fail on the difference.
The compiled styles.css still prunes to exactly the tokens the components use.
Fonts
Ekanos sets text in Inter, the assistant's replies included, and
headings in Urbanist at 400–500 (font-heading with font-heading-bold
or font-heading-semibold), both loaded via next/font. This package only
names them in --font-sans / --font-heading / --font-voice, followed by
a system fallback stack — no webfont is bundled or fetched. For exact
typographic parity, load them yourself:
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:opsz,[email protected],100..900&family=Urbanist:[email protected]&display=swap"
/>
Without them, everything still renders — in your platform's UI font.
Icons: Phosphor, included
Icon draws a Phosphor glyph (MIT) as an <svg>
holding the icon font's character, so shadcn's [&_svg] selectors size it.
The fonts ship in dist/fonts/ and styles.css / base.css declare them, so
there is nothing to install. Your bundler must handle .woff2 url()s in
CSS, which Next.js, Vite and webpack 5 do out of the box (esbuild needs
--loader:.woff2=file). A browser downloads only the weights a page draws.
Name icons the way phosphoricons.com does: "house", "caret-down",
"github-logo". A filled state glyph (a selected star) is "star:fill"; it
stays filled in every style. Re-skin everything below a point to one weight:
import { IconStyleOverrideProvider } from '@ekanos/ui/icon';
<IconStyleOverrideProvider value="bold">{children}</IconStyleOverrideProvider>;
The styles are light, regular, bold, solid (Phosphor's fill weight)
and duotone. A Font Awesome name from before the move ("fa-house",
"fa-brands fa-github") still draws its Phosphor equivalent, and a name that
matches no icon draws shapes rather than vanishing. Check a name with
isRenderableIconName().
An icon is 16px unless a size class (size-5, or a component's [&_svg]
rule) or --icon-glyph-size on a container says otherwise. Like a lucide
icon, Icon takes SVG props (aria-label, role, data-icon).
Runtime notes
-
Primitives are Base UI, not Radix. Everything popup-shaped is built on
@base-ui/react(declared dependency). NoasChild— polymorphic parts take arenderprop:<DropdownMenuTrigger render={<Button />}>Menu</DropdownMenuTrigger>. -
Transresolves against the host's next-intl messages. It is a client component that reads next-intl's context, so it must render under aNextIntlClientProvider. The Ekanos host mounts one at its root and@ekanos/harness'sRootLayoutmounts one for you; anywhere else (a Storybook, a bare unit test) wrap the tree in one. Empty messages are enough, because a missing key rendersdefaults:<NextIntlClientProvider locale="en" messages={{}} timeZone="UTC"> <Trans i18nKey="myIntegration:connect" defaults="Connect {{service}}" values={{ service: 'Drive' }} /> </NextIntlClientProvider>Keys keep the
ns:keyshape anddefaultskeep{{var}}interpolation. A key resolves to the host's message when it exists; otherwisedefaults(interpolated, withcomponents), thenchildren, then the key itself. With no provider at all,Transthrows. The old react-i18next build instead rendered its children silently. -
Spinneris self-contained. It draws an inline SVG rather than a Font Awesome glyph, so unlikeIconit needs no icon web font — only the Tailwind utilities it emits (animate-spin,size-*,text-*). It honoursprefers-reduced-motionviamotion-reduce:animate-none, freezing the arc into a static busy glyph. -
'use client'is preserved. Client entries (icon,form,tooltip,label,select,dropdown-menu,ai-prompt-input,dialog,separator) ship with the directive at the top of their bundle.utils,if,trans, andspinnerare server-safe and carry no directive, socn()etc. remain usable from server components. -
aiis a types-only dependency.@ekanos/ui/ai-prompt-inputusesChatStatus/FileUIParttypes from theaipackage in its public props; noaicode is imported at runtime. -
Base UI patch divergence (minor). The monorepo patches
@base-ui/[email protected]to widen a trigger drag-boundary offset (BOUNDARY_OFFSET2 → 24 in menu/select/combobox triggers). The published artifact resolves the unpatched package from npm, so partner-side menus keep upstream's slightly tighter press-and-drag selection boundary. Cosmetic only.
Peer dependencies
You provide: react / react-dom (^19), react-hook-form and
@hookform/resolvers (for form usage). Everything else the components need at
runtime is a declared dependency of this package.
Building / verifying
pnpm --filter @ekanos/ui build # tsdown → dist/, then the CSS build → the stylesheets
pnpm --filter @ekanos/ui pack:test # clean-room publish gate
The build runs in two stages, in this order for a reason: tsdown bundles the
components, then the CSS build scans the built dist/*.js for class
strings and compiles exactly those through Tailwind. Deriving the utility set
from the build output rather than a hand-kept list is what makes it impossible
to add a component whose classes have no CSS.
That script carries four fatal gates:
- dangling — every
var(--x)the token map reaches for must be defined. - coverage — the same class strings are re-compiled against Ekanos's full token map, and the build fails if a component has begun using a token this package doesn't ship. Without this, such a utility would just silently emit nothing.
- lockstep —
tokens.cssspells its dark values out twice (a media query and an explicit selector, because CSS can't share a block between them); both must stay in agreement. - source parity — every value in
tokens.cssmust equal Ekanos's@kit/ui/styles/tokens.css, andtheme.cssmust map every color, shadow, radius and font entry of Ekanos's@theme. Coverage proves the names exist; this proves the values match.src/styles/__tests__/token-parity.test.tsruns the same check with the package tests.
pack:test packs the tarball, asserts dist-only contents, fails on any
@kit/ leak, asserts 'use client' preservation, then installs the tarball
into a temp project outside the workspace and typechecks
(skipLibCheck: false) + esbuild-bundles a consumer importing every subpath.
It also gates the stylesheets: all four must ship, clear a size floor, still
contain known utilities and both theme states, resolve through esbuild as
@ekanos/ui/styles.css, and carry no icon-font data — @font-face, a
webfont reference or a glyph table in dist/ fails the build, for the same
licensing reason the Makerkit lineage check exists.
prepack runs the build, so a publish can never ship a stale or missing
dist/.