Installation

The whole design system is one public npm package. Install it in any React 19 + Tailwind v4 project and every component on this site is available — no registry, no licence key, no account.

@comitor/ui on npm →New to the system? Read the introduction first

1. Install

The optional peers are per entry point — skip the ones you will not import.

terminal
# the package
pnpm add @comitor/ui

# required peers
pnpm add react react-dom lucide-react
pnpm add -D tailwindcss @tailwindcss/postcss

# optional peers — only for the entry you use
pnpm add next next-themes   # @comitor/ui/shell
pnpm add recharts           # @comitor/ui/chart
pnpm add @tanstack/react-table  # @comitor/ui/data-grid
pnpm add react-hook-form    # @comitor/ui/form
pnpm add @uppy/core @uppy/xhr-upload  # @comitor/ui/uploader

# @comitor/ui/editor and @comitor/ui/chat — every @tiptap package at ONE version
pnpm add @tiptap/core @tiptap/pm @tiptap/react @tiptap/starter-kit @tiptap/markdown   @tiptap/extensions @tiptap/extension-list @tiptap/extension-table @tiptap/extension-image   @tiptap/extension-mention @tiptap/suggestion @tiptap/extension-paragraph   @tiptap/extension-code-block-lowlight lowlight highlight.js
PropTypeDefaultDescription
react, react-dom^19.0.0requiredAlways.
tailwindcss^4.1.0requiredAlways — styles.css only means anything once Tailwind v4 compiles it.
lucide-react>=0.540.0requiredAlways. Shared so the bundle never carries two icon sets. Version 1.x dropped brand icons.
next>=15optionalOnly for @comitor/ui/shell.
next-themes>=0.4optionalOnly for @comitor/ui/shell. Context singleton — app and package must resolve to one copy.
recharts>=2.15.0optionalOnly for @comitor/ui/chart. Typed for both 2.x and 3.x.
@tanstack/react-table>=8.21.0 <9optionalOnly for @comitor/ui/data-grid. A peer rather than a dependency so your ColumnDef and the grid's resolve to one copy of the types.
react-hook-form^7.54.0optionalOnly for @comitor/ui/form. Context singleton, same caveat as next-themes.
@uppy/core, @uppy/xhr-upload^6.0.0optionalOnly for @comitor/ui/uploader. Uppy is driven headless — the markup stays ours.
@tiptap/* (13 packages)^3.31.0optionalOnly for @comitor/ui/editor and @comitor/ui/chat — and all of them at ONE version: they pin @tiptap/core and @tiptap/pm to the exact number, and an extension you write must use that same copy.
lowlight^3.0.0optionalOnly for @comitor/ui/editor and @comitor/ui/chat — code-block highlighting. It pulls in highlight.js ^11, a peer of Tiptap's code-block extension, so install that too.

2. Load the stylesheet

This is the step that goes wrong most often, and it fails in a way that looks like something else entirely.

app/globals.css
/* app/globals.css — exactly these two lines, in this order */
@import "tailwindcss";
@import "@comitor/ui/styles.css";

Order matters, and the file goes nowhere else.

  • Tailwind must be imported first. The package stylesheet is mostly @theme, @custom-variant and @source — at-rules that mean nothing unless Tailwind compiles them. Reverse the two lines and every utility disappears.
  • Never import it from layout.tsx. The CSS variables would still land, so the colours look roughly right, but no utility gets generated and the layout breaks.
  • Do not add tokens, hex values or utilities of your own to this file. The package is the single source of truth for all of them.
postcss.config.mjs
/* postcss.config.mjs — Tailwind v4 needs one plugin and nothing else.
   No autoprefixer, no postcss-import; v4 absorbed both. */
const config = {
  plugins: {
    "@tailwindcss/postcss": {}
  }
}

export default config

What that import does to your markup

Everything above is about getting the file loaded. Once it is, its @layer base block styles your elements too, not only the ones that come out of the package — four rules that no class of yours asked for and none of which announce themselves.

@comitor/ui/styles.css
/* @comitor/ui/styles.css — the whole @layer base block */
@layer base {
  /* Default border colour for EVERY element. */
  * { @apply border-border outline-ring/50; }

  body { @apply bg-background text-foreground font-sans antialiased; }

  /* Tailwind v4 dropped this from preflight; the package puts it back. */
  button:not(:disabled):not([aria-disabled="true"]),
  [role="button"]:not(:disabled):not([aria-disabled="true"]),
  label[for] { cursor: pointer; }

  /* The focus signature: 2px, offset 2px, --ring. */
  :focus-visible { @apply outline-2 outline-offset-2 outline-ring; }
}
  • The * rule is the one that bites. --border is the decorative rule colour, 1.16:1 and deliberately under every contrast threshold — so a control that writes border without naming a colour gets a border you cannot see, and nothing reports it. Controls always name one: border-input, or border-choice-edge for a checkbox or radio. See Colours.
  • Focus is a page-wide signature. Every focusable element gets a 2px --ring outline at 2px offset, with no class on your side. Components that follow a different convention opt out explicitly — menu and command rows swap it for an --accent background, because focus there moves under the arrow keys.
  • body carries the background, text colour, font and antialiasing, so a page that renders nothing but text is already on-palette.

The cursor rule, as a worked example (1.7.1)

Tailwind v3’s preflight carried button, [role="button"] { cursor: pointer }. Tailwind v4 dropped it to follow the browser default, so on v4 every <button> computes cursor: default — no build error, no warning, nothing to grep for. On one real toolbar the <a> had the hand and the two <button>s beside it did not, though all three looked identical; a ghost button on a flat background then reads as a label. The package restores it in the base layer rather than in Button, so the raw <button>s you write are covered by the same rule — sprinkling cursor-pointer by hand only holds until the next component someone writes.

MarkupComputed cursor
<button>pointer
<button disabled>default
<button aria-disabled="true">1.7.0 gave pointerdefault
<button role="button" disabled>1.7.0 gave pointerdefault
<div role="button">pointer
<div role="button" aria-disabled="true">auto
<label for="email">pointer
<label> (no for)default
<fieldset disabled> <button>default

Disabled is excluded twice because that is two tests, not one — and both tests sit on both selectors. :not(:disabled) covers native controls; :not([aria-disabled="true"]) covers the Radix ones, which stay focusable on purpose and therefore never carry the attribute the first test looks for. The off values differ only because the elements have different user-agent defaults — default for a button, auto for a div; neither is a hand. A control inside <fieldset disabled> matches :disabled per the HTML spec, so a read-only screen built that way invites nothing.

1.7.0 paired one test per selector, and that was the bug. It shipped button:not(:disabled), [role="button"]:not([aria-disabled="true"]) — so aria-disabled only ever excluded the [role="button"] branch, and a plain <button aria-disabled="true"> — the exact Radix shape the second test was written for — kept the hand. 1.7.1 puts both tests on both selectors.

Worth knowing why it survived a measurement pass: the six cases checked at the time used <div role="button" aria-disabled="true"> for the aria branch — the one selector that did carry the guard. Every case chosen was a case that passed. The table above is nine cases for that reason, and the real-world one it missed was Calendar: react-day-picker marks the month arrows aria-disabled with no disabled attribute, so at the ends of a bounded range they invited a click that did nothing.

label[for] is in because a label with a target is a control by proxy: clicking it focuses or toggles the thing it names. A bare <label> points at nothing and does nothing, so it deliberately gets no rule. This page is the proof: it loads the same stylesheet, so hovering any button here already gives you the hand.

One rule outside the layer: fields at 16px on iOS

Since 3.13.4 the stylesheet carries one more block that reaches your markup, and it sits outside every @layer on purpose, so it beats utilities without !important. WebKit on iOS and iPadOS zooms the whole page when a field with text under 16px takes focus, and does not zoom back out — on an iPhone a sign-in screen grew past the viewport and lost its right edge. So on those two systems only (no other browser matches the @supports test), every typing field is held at 16px — or at the size of the text around it, when that is larger. Android and desktop keep the sizes the components set: 16px below md, 14px from md up (Input).

@comitor/ui/styles.css
/* @comitor/ui/styles.css — section 5e, outside every @layer (3.13.4) */
@supports (-webkit-touch-callout: none) {
  input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"], [type="color"], [type="hidden"]),
  textarea,
  select,
  [contenteditable="true"] {
    font-size: max(16px, 1em);
  }
}

So do not lock zoom in your viewport. A maximumScale of 1, or userScalable set to false, in the viewport export of app/layout.tsx — or the same maximum-scale in a hand-written <meta name="viewport"> — was the usual way round the focus zoom. It is no longer needed, and it does harm: it stops people pinching to zoom on Android (WCAG 1.4.4), and iOS ignores it for the pinch gesture anyway, so only the harm is left. If your layout has one, take it out; the rest of the export — themeColor and so on — stays.

3. Load the font

The package points --font-sans at --font-inter and expects your app to define it. Leave it undefined and the whole declaration is invalid at computed-value time — the fallbacks after it are never reached.

app/layout.tsx
// app/layout.tsx
import { Inter } from 'next/font/google'

// The package consumes --font-inter; it never loads a font itself.
// The vietnamese subset is not optional — without it Vietnamese
// diacritics fall back to a system font and the text visibly shifts.
const inter = Inter({
  subsets: ['latin', 'vietnamese'],
  weight: ['400', '500', '600', '700', '800'],
  display: 'swap',
  variable: '--font-inter',
})

4. Wire the providers

Four independent display axes, all at the root layout, all per-user and per-device. Each renders its own anti-flash script — you add nothing to <head>. Only the theme axis is strictly required; drop the other three if you do not want to offer them.

app/layout.tsx
// app/layout.tsx
import { ContrastProvider, DensityProvider, FontSizeProvider, ThemeProvider } from '@comitor/ui/shell'
import './globals.css'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    // suppressHydrationWarning is required: all four scripts touch <html>
    // before React hydrates, so server and client HTML never match.
    <html lang="en" suppressHydrationWarning className={inter.variable}>
      <body className="min-h-dvh bg-background font-sans text-foreground antialiased">
        <ThemeProvider>
          <ContrastProvider>
            <DensityProvider>
              <FontSizeProvider>{children}</FontSizeProvider>
            </DensityProvider>
          </ContrastProvider>
        </ThemeProvider>
      </body>
    </html>
  )
}

Not on Next? Skip this step and the /shell entry entirely — the main entry has no framework dependency. See Display Axes for what each one does.

5. Set the language

Since 3.0.0 every string the package renders is English unless told otherwise — visible text, aria-label and sr-only text, and the words a screen reader hears while a card is dragged. Vietnamese is built in. Choose it once, at the root, with ComitorLocaleProvider from the main entry. An English-only app can skip this step; every other app cannot.

app/layout.tsx
// app/layout.tsx — a Server Component
import { ComitorLocaleProvider } from '@comitor/ui'
import { ContrastProvider, DensityProvider, FontSizeProvider, ThemeProvider } from '@comitor/ui/shell'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    // lang is yours to set — the provider never touches <html>, and a screen
    // reader picks its pronunciation rules from this attribute.
    <html lang="vi" suppressHydrationWarning className={inter.variable}>
      <body className="min-h-dvh bg-background font-sans text-foreground antialiased">
        {/* Independent contexts: the nesting order does not matter. */}
        <ComitorLocaleProvider locale="vi">
          <ThemeProvider>
            <ContrastProvider>
              <DensityProvider>
                <FontSizeProvider>{children}</FontSizeProvider>
              </DensityProvider>
            </ContrastProvider>
          </ThemeProvider>
        </ComitorLocaleProvider>
      </body>
    </html>
  )
}

Forgetting this line fails silently. English is a valid default, so a Vietnamese app without the provider shows “Cancel” and “Select…” in the middle of a Vietnamese screen, and announces its buttons in English — no build error, no console warning, and nothing a type checker can see.

A Comitor app: the user’s language

In a Comitor product the language is a setting on the user’s Account, read through next-intl — never a constant. Wrap at the root layout, inside NextIntlClientProvider, not just around AppShell: sign-in and error pages render outside the shell, and so do the portals every dialog and popover mounts into. AppShell does take a locale prop, but only as a shortcut for apps with no root layout of their own — the provider it builds does not inherit messages or dateFnsLocale from one above it, and adding or removing the prop remounts the whole shell.

app/layout.tsx
// app/layout.tsx — a Comitor app. The language is the USER'S, kept on their
// Account and read through next-intl. Never pin "vi" in a product.
import { NextIntlClientProvider } from 'next-intl'
import { getLocale } from 'next-intl/server'
import { ComitorLocaleProvider } from '@comitor/ui'

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const locale = await getLocale()

  return (
    <html lang={locale} suppressHydrationWarning>
      <body>
        <NextIntlClientProvider>
          {/* Here, not around AppShell: sign-in, error pages and dialog
              portals all render outside the shell. */}
          <ComitorLocaleProvider locale={locale}>{children}</ComitorLocaleProvider>
        </NextIntlClientProvider>
      </body>
    </html>
  )
}

What the locale changes

locale is a BCP 47 tag, and it decides two things. It picks the built-in string table — anything starting with vi gets Vietnamese, every other tag gets English — and it drives every Intl format the package uses: times, numbers in chart tooltips, the 12- or 24-hour clock. Switch the preview below and watch both. The words the app itself owns — the invoice title, the field labels — switch too, because this demo passes them from its own dictionary the way next-intl would; the provider never touches them.

Invoice INV-0431

Created

useComitorLocale() → locale “en” · strings “en” · date-fns “en-US”

value → due 2026-10-05 · reminder “14:30”

  • en-GB keeps English words and changes every format. The time picker drops AM/PM because Intl says British English runs a 24-hour clock. The date field only follows because the demo also passes dateFnsLocale={enGB}: the package maps nothing but vi → vi, and every other tag to date-fns’ enUS. Without it, a British reader types 12/10/2026 and gets the 10th of December.
  • The value never changes shape. The time picker reports “14:30” whether it shows 2:30 PM or 14:30, and the date picker hands back a Date — the locale is presentation, and nothing you store depends on it.
  • Counts follow the language too. TablePagination and SearchFilterBar group digits for the language of their text: 1,248 in English, 1.248 in Vietnamese. Before 3.1.0 they went in raw.

Which string wins

Every string in the package resolves the same way, top to bottom:

  1. The component’s own prop

    labels · placeholder · clearLabel · locale · itemLabel…

    One instance, one exception — a close button named for what it closes, a unit that is this table’s content. labels is a Partial, so passing one key keeps the rest.

  2. The nearest ComitorLocaleProvider

    locale · messages · dateFnsLocale

    The language of everything beneath it. A second provider nested inside gives one region of the page another language; the inner one wins for its subtree.

  3. English

    DEFAULT_COMITOR_LOCALE = “en”

    What renders with no provider at all: right for an international app, for Storybook and for tests — and silently wrong for a Vietnamese app that skipped this step.

Keys that put a number inside a sentence are functions, not templates — range(start, end, total, unit), selected(count), itemLabel(value, max) — because word order around a number is not the same in every language, and English also has to choose between “1 star” and “2 stars”. The DEFAULT_*_LABELS constants are still exported, now as the English tables; the Vietnamese one for any component is comitorMessagesVi.<namespace>.

Props that carry a function

A root layout is a Server Component, and a Server Component can hand a client component strings, numbers and plain objects — not functions. That rules out two of the provider’s three props there: dateFnsLocale, because a date-fns locale is an object full of functions, and any messages override that touches a function-valued key. Pass either from the root layout and the page does not render at all: Functions cannot be passed directly to Client Components. The fix is a small client component of your own that owns those values — which is also the right home for them, because the object must keep one stable reference: a fresh messages object on every render re-renders every component that reads a string.

app/comitor-locale.tsx
// app/comitor-locale.tsx — needed as soon as a prop carries a FUNCTION
'use client'

import { enGB } from 'date-fns/locale'
import { ComitorLocaleProvider, type ComitorMessagesOverride } from '@comitor/ui'

// Module scope: one stable reference. A fresh object on every render
// re-renders every component that reads a string.
const MESSAGES: ComitorMessagesOverride = {
  shell: { workspace: 'Company' },
  tablePagination: { range: (start, end, total, unit) => `${start}–${end} / ${total} ${unit}` },
}

export function ComitorLocale({ locale, children }: { locale: string; children: React.ReactNode }) {
  return (
    <ComitorLocaleProvider
      locale={locale}
      messages={MESSAGES}
      // The package maps only vi → vi and everything else → enUS for date-fns.
      dateFnsLocale={locale === 'en-GB' ? enGB : undefined}
    >
      {children}
    </ComitorLocaleProvider>
  )
}

// app/layout.tsx (still a Server Component) then renders
//   <ComitorLocale locale={locale}>{children}</ComitorLocale>

Changing a few words, or adding a language

messages is an override by namespace — one per component, named after it (dataTable, shell, kanban…) — and inside a namespace, by key. Use it for the words your domain calls something else (“Company” for “Workspace”), or to supply a whole table for a language the package does not ship. Start from comitorMessagesEn and the type checker lists every key still untranslated; the Intl half of the locale already works for any tag.

app/comitor-locale.tsx
'use client'

import { ComitorLocaleProvider, comitorMessagesEn, type ComitorMessages } from '@comitor/ui'

// A language the package does not ship: start from the English table so the
// type checker tells you every key you have not translated yet.
const MESSAGES_FR: ComitorMessages = {
  ...comitorMessagesEn,
  confirmDialog: { ...comitorMessagesEn.confirmDialog, confirmLabel: 'Confirmer', cancelLabel: 'Annuler' },
  tablePagination: {
    ...comitorMessagesEn.tablePagination,
    range: (start, end, total, unit) => `${start}–${end} sur ${total} ${unit}`,
  },
  // …one namespace per component
}

// "fr" is not a built-in table, so the strings come from messages — while
// Intl formatting (times, numbers, the 24-hour clock) already follows "fr".
<ComitorLocaleProvider locale="fr" messages={MESSAGES_FR}>{children}</ComitorLocaleProvider>

Region is not language

Two defaults are deliberately not derived from the locale, because they describe where the business is rather than what the reader speaks. PhoneInput has no default country, and MaskInput formats USD unless told otherwise. Read both from the workspace’s settings.

region settings
// Country and currency belong to the WORKSPACE, not to the reader's language:
// a Vietnamese speaker on an English interface can still invoice in VND.
<PhoneInput defaultCountry={workspace.country} />      // no default — "VN" is not assumed
<MaskInput mask="currency" currency={workspace.currency} />  // defaults to USD, like upstream

// ⚠ With no country selected, PhoneInput leaves a domestic number such as
// 0912 345 678 as typed instead of emitting +84912345678.

Outside React, and outside the tree

A Server Component, an email template or a route handler has no context to read, so the tables are also exported as plain data: getComitorMessages(locale, override?) returns the same merged table the provider builds, from a module with no 'use client' behind it.

app/invoices/[id]/page.tsx
// app/invoices/[id]/page.tsx — a Server Component, or an email template, or a
// route handler: no React context there, so read the table directly.
import { getLocale } from 'next-intl/server'
import { getComitorMessages } from '@comitor/ui'

export default async function InvoicePage() {
  const locale = await getLocale()
  const t = getComitorMessages(locale) // a pure module — no 'use client' behind it

  return <p>{t.tablePagination.pageStatus(1, 12)}</p> // "Trang 1 / 12" for vi
}
  • Put the toaster inside the provider. toastPromise runs outside React, so when you give it no loading text it borrows the language of the toaster that renders it — the Toaster from @comitor/ui or the shell’s SonnerToaster. The shell one hands its language over only since 3.0.1; under 3.0.0 its default stayed English.
  • DataGrid follows the same provider. Its own locale prop and DataGridLocaleProvider still win when present — see Data Grid.
  • Set <html lang> yourself. The provider changes strings and formats, never the document’s language attribute — and that attribute is what a screen reader uses to choose its pronunciation.

6. Render something

That is the whole setup. Every component comes from the same import.

Invite a teammate

They will get access to this workspace.

Active

The nine entry points

The split is not tidiness. The package builds one ES module per source file, so a barrel that re-exported a file importing next would force every consumer to install Next or fail at resolve time. Each optional peer therefore sits behind its own door.

PropTypeDefaultDescription
@comitor/uiTier 1 + Tier 2—Primitives, composites, cn, and the design tokens. This is the one you import from most.
@comitor/ui/shellTier 3next, next-themesAppShell, Sidebar, AppHeader, the five display-axis providers, and the brand marks.
@comitor/ui/chartchartsrechartsChart* (shadcn) and Comitor*Chart plus CHART_COLORS.
@comitor/ui/data-gridspreadsheet grid@tanstack/react-tableDataGrid, useDataGrid, the toolbar menus and the undo/redo hook — bulk entry with virtualised rows and an Excel-shaped clipboard.
@comitor/ui/formformsreact-hook-formThe shadcn binding: Form, FormField (a Controller wrapper), FormMessage…
@comitor/ui/uploaderuploads@uppy/core, @uppy/xhr-uploadFileUpload, ImageUploadField and the useFileUpload hook — Uppy driven headless, so the markup is ours.
@comitor/ui/editortext editors@tiptap/*, lowlightMarkdownEditor (stores Markdown, a Linear-style bubble) and RichTextEditor (stores HTML, a pinned toolbar), on one Tiptap core.
@comitor/ui/chatAI conversation@tiptap/*, lowlightChatThread, ChatMessage, ChatSteps, ChatSources and ChatComposer — the frame of an assistant conversation. Messages are drawn by the read-only MarkdownEditor, so it needs the editor's peers and nothing more.
@comitor/ui/tokenstokens—Token values as plain TS objects, for canvas, PDF and email. Never for Tailwind code — use the class.
app/(app)/layout.tsx
// Tier 3 — the whole application frame in one component.
import { AppShell } from '@comitor/ui/shell'

export default function AppLayout({ children }: { children: React.ReactNode }) {
  return (
    <AppShell nav={NAV} workspaces={WORKSPACES} apps={APPS} user={USER}>
      {children}
    </AppShell>
  )
}

Three things worth knowing early

Twenty names appear at more than one entry

export * only errors on a duplicate name within the same entry. A name exported from two different entries is completely silent — no build error, no warning, and the two imports read identically at the call site. So the only way to answer “is this one binding or two?” is to look it up. Twenty names are in that position, and exactly two of them are a real fork.

NameAt @comitor/uiAlso atSame binding?
FormFieldFormFieldPropsThe Tier 2 composite — a 12-column grid holding label, control, description and error.@comitor/ui/formThe wrapper around react-hook-form’s Controller, which is the shadcn API.No — two different components
normalizeVigetVietnameseInitialsDefined once, in the Tier 2 text utilities.@comitor/ui/shellRe-exports that exact function. The shell keeps no copy of its own.Yes — one function body
goldashbrandNeutralaccentaccentInkaccentForegroundsemantichighContraststatuschartlayouttypographyThemeModegetAvatarToneColorsgetAvatarToneIndexAvatarToneColorsThe main entry re-exports the token module whole.@comitor/ui/tokensThe same module, narrowed to just the constants for places that have no Tailwind.Yes — one module, two doors

The rule for using it: take the binding at @comitor/ui day to day, and reach for /form only once the app already uses react-hook-form. Only FormField and FormFieldProps are two implementations — never import both. Every other name in the table is one binding seen through two doors, so which door you use changes nothing but your import line. That is worth keeping: getVietnameseInitials once had two bodies that upper-cased differently, and the same name quietly returned different initials depending on where you imported it from.

Two near-misses that are not on the list. Toaster and SonnerToaster are two different components with two deliberately different names, so they can never collide — the first takes a theme prop, the second reads next-themes itself. And useIsMac ships from @comitor/ui only; /shell uses it internally but does not re-export it.

A Server Component can render the package, not configure it with functions

Most components carry 'use client' inside the package, so a server page renders them without a directive of its own — that part just works. What cannot cross from the server to the client is a function: a label that interpolates a number, a formatter, a date-fns locale. The type checker accepts all three, and the page then fails to render. Hooks (useRating, useStepper, useComitorLocale…) throw on the server by design. The full map of what runs where is on Server and client.

app/products/[id]/page.tsx
// app/products/[id]/page.tsx — a Server Component
import { Rating } from '@comitor/ui'

// ✓ strings, numbers and booleans cross the server → client boundary
<Rating value={4.5} step={0.5} readOnly aria-label="Rated 4.5 out of 5" />

// ✗ a function does not. The page fails to render with
//   "Functions cannot be passed directly to Client Components…"
<Rating value={4.5} step={0.5} readOnly itemLabel={(value) => `${value} étoiles`} />

// Same for a date-fns locale object, a labels key that interpolates a number,
// a formatter. Move the function into a component that starts with
// 'use client', and render THAT from the server page.

All nine entries load under plain Node ESM

Not just inside a bundler. Every entry resolves through the package’s exports map under Node on its own, which is what you are standing on any time the package is used outside a build: a script that renders an email or a PDF from /tokens, a test runner in a node environment, a codegen step. Import by package name rather than reaching into dist/ — the deep path skips the exports map and is not a supported entry.

scripts/render-email.mjs
// A plain Node script — no bundler, no transpile step, no framework.
// Import by package NAME so resolution goes through the exports map;
// reaching into dist/ bypasses it and is not supported.
import { semantic, gold } from '@comitor/ui/tokens'
import { getVietnameseInitials } from '@comitor/ui'

// /shell resolves too, and it is the one you would bet against:
// it reaches for next/*. Every internal specifier in the package
// carries its .js extension, which is what Node demands and what
// a bundler would have papered over.
import { isNavItemActive } from '@comitor/ui/shell'

const cell = `<td style="background:${semantic.light.card};color:${gold[500]}">`
console.log(getVietnameseInitials('Nguyễn Văn An')) // NA

This is a property worth stating because it is so easy to lose. A bundler supplies a missing file extension; Node does not, so a single internal specifier written as next/link instead of next/link.js is invisible in every Next app and fatal in a script — ERR_MODULE_NOT_FOUND at import, before any of your code runs. A publish gate loads all nine entries by name and fails the release if any of them will not come up, so this holds for the version you install rather than only for the one that was tested.

Where to go next