@ekanos/ui
v0.18.0
Published
Ekanos-styled UI primitives for building Ekanos integrations outside the monorepo. A curated, published slice of the internal @kit/ui library.
Readme
@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/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/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, Font Awesome 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 |
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,
assistant, --favorite, 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 runsThe 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 everything in Inter, and the assistant's replies in
Newsreader, 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=Newsreader:opsz,[email protected],200..800&display=swap"
/>Without them, everything still renders — in your platform's UI font.
Icons: you must supply Font Awesome
Icon renders Font Awesome markup (<i class="fa-solid fa-house">) and this
package ships no icon font, because Ekanos uses Font Awesome Pro (7.2.0,
© Fonticons, Inc.) under a commercial licence. Redistributing it inside an MIT
package would hand reuse rights we do not hold to everyone who installs this;
pack:test fails the build if any font data ever reaches dist/.
If you hold a Font Awesome Pro licence, load your own Pro CSS (or kit) and every icon renders exactly as it does in Ekanos.
If you do not, use Font Awesome Free — but read this, because the fit is imperfect:
npm install @fortawesome/fontawesome-freeimport '@fortawesome/fontawesome-free/css/all.min.css';Free ships fa-solid, fa-regular and fa-brands. It has no fa-light or
fa-duotone family, and it is missing many Pro glyphs — and fa-light is
exactly what @kit/ui's resolver falls back to for an icon name it doesn't
recognise. Force everything into a family Free actually has:
import { IconStyleOverrideProvider } from '@ekanos/ui/icon';
<IconStyleOverrideProvider value="solid">{children}</IconStyleOverrideProvider>;Any glyph Free still doesn't have falls back to a question mark rather than
vanishing (--ekanos-icon-fa-fallback, default '\f059') — and in
development, Icon / resolveIcon / renderIcon console.warn once per
distinct name that isn't in Free's set, so you find out from your terminal
instead of a screenshot review. Check a name yourself with
pnpm --filter @ekanos/ui check:fa-free-names (it verifies the generated
src/_impl/fa-free-icon-names.generated.ts against the installed
@fortawesome/fontawesome-free; run it with --write after bumping that
package).
If you load no Font Awesome at all, icons do not disappear silently: this
package's CSS draws a placeholder box (□) in their place, so "I forgot the
icon font" is visibly different from "my code is broken". Change or disable it
with --ekanos-icon-fallback.
Note that FA glyphs are sized by font-size, not box dimensions, and Icon
derives that from the first h-* / w-* / size-* token in className in JS
— so <Icon className="h-5 w-5" /> is the right visual size even on the
no-Tailwind path where those utilities have no CSS rule.
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 gateThe 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/.
