Get started
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.
# 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| Prop | Type | Default | Description |
|---|---|---|---|
react, react-dom | ^19.0.0 | required | Always. |
tailwindcss | ^4.1.0 | required | Always — styles.css only means anything once Tailwind v4 compiles it. |
lucide-react | >=0.540.0 | required | Always. Shared so the bundle never carries two icon sets. Version 1.x dropped brand icons. |
next | >=15 | optional | Only for @comitor/ui/shell. |
next-themes | >=0.4 | optional | Only for @comitor/ui/shell. Context singleton — app and package must resolve to one copy. |
recharts | >=2.15.0 | optional | Only for @comitor/ui/chart. Typed for both 2.x and 3.x. |
@tanstack/react-table | >=8.21.0 <9 | optional | Only 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.0 | optional | Only for @comitor/ui/form. Context singleton, same caveat as next-themes. |
@uppy/core, @uppy/xhr-upload | ^6.0.0 | optional | Only for @comitor/ui/uploader. Uppy is driven headless — the markup stays ours. |
@tiptap/* (13 packages) | ^3.31.0 | optional | Only 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.0 | optional | Only 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 — 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-variantand@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 — Tailwind v4 needs one plugin and nothing else.
No autoprefixer, no postcss-import; v4 absorbed both. */
const config = {
plugins: {
"@tailwindcss/postcss": {}
}
}
export default configWhat 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 — 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.--borderis the decorative rule colour, 1.16:1 and deliberately under every contrast threshold — so a control that writesborderwithout naming a colour gets a border you cannot see, and nothing reports it. Controls always name one:border-input, orborder-choice-edgefor a checkbox or radio. See Colours. - Focus is a page-wide signature. Every focusable element gets a 2px
--ringoutline 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--accentbackground, because focus there moves under the arrow keys. bodycarries 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.
| Markup | Computed cursor |
|---|---|
<button> | pointer |
<button disabled> | default |
<button aria-disabled="true">1.7.0 gave pointer | default |
<button role="button" disabled>1.7.0 gave pointer | default |
<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 — 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
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
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 — 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 — 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
Intlsays British English runs a 24-hour clock. The date field only follows because the demo also passesdateFnsLocale={enGB}: the package maps nothing butvi→vi, and every other tag to date-fns’enUS. Without it, a British reader types12/10/2026and 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 aDate— the locale is presentation, and nothing you store depends on it. - Counts follow the language too.
TablePaginationandSearchFilterBargroup digits for the language of their text: 1,248 in English, 1.248 in Vietnamese. Before3.1.0they went in raw.
Which string wins
Every string in the package resolves the same way, top to bottom:
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.
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.
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 — 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.
'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.
// 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 — 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.
toastPromiseruns outside React, so when you give it noloadingtext it borrows the language of the toaster that renders it — theToasterfrom@comitor/uior the shell’sSonnerToaster. The shell one hands its language over only since3.0.1; under3.0.0its default stayed English. DataGridfollows the same provider. Its ownlocaleprop andDataGridLocaleProviderstill 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.
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.
| Prop | Type | Default | Description |
|---|---|---|---|
@comitor/ui | Tier 1 + Tier 2 | — | Primitives, composites, cn, and the design tokens. This is the one you import from most. |
@comitor/ui/shell | Tier 3 | next, next-themes | AppShell, Sidebar, AppHeader, the five display-axis providers, and the brand marks. |
@comitor/ui/chart | charts | recharts | Chart* (shadcn) and Comitor*Chart plus CHART_COLORS. |
@comitor/ui/data-grid | spreadsheet grid | @tanstack/react-table | DataGrid, useDataGrid, the toolbar menus and the undo/redo hook — bulk entry with virtualised rows and an Excel-shaped clipboard. |
@comitor/ui/form | forms | react-hook-form | The shadcn binding: Form, FormField (a Controller wrapper), FormMessage… |
@comitor/ui/uploader | uploads | @uppy/core, @uppy/xhr-upload | FileUpload, ImageUploadField and the useFileUpload hook — Uppy driven headless, so the markup is ours. |
@comitor/ui/editor | text editors | @tiptap/*, lowlight | MarkdownEditor (stores Markdown, a Linear-style bubble) and RichTextEditor (stores HTML, a pinned toolbar), on one Tiptap core. |
@comitor/ui/chat | AI conversation | @tiptap/*, lowlight | ChatThread, 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/tokens | tokens | — | Token values as plain TS objects, for canvas, PDF and email. Never for Tailwind code — use the class. |
// 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.
| Name | At @comitor/ui | Also at | Same binding? |
|---|---|---|---|
FormFieldFormFieldProps | The 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 |
normalizeVigetVietnameseInitials | Defined 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 |
goldashbrandNeutralaccentaccentInkaccentForegroundsemantichighContraststatuschartlayouttypographyThemeModegetAvatarToneColorsgetAvatarToneIndexAvatarToneColors | The 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 — 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.
// 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')) // NAThis 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
Colours
The three-role contract every component follows, and the second palette.
Components
The Tier 1 primitives — buttons, inputs, overlays, menus, navigation.
Composites
Data tables, kanban boards, steppers, date, time and phone inputs, status chips — the patterns every app repeats.
Shell
The Tier 3 application frame, and the five display axes.