Foundations
Server and client
@comitor/ui is built one module per source file, so each file keeps its own "use client" directive. That makes "can a React Server Component call this?" a real question with a per-name answer — and it is not the answer most people guess. Seven functions that do nothing but concatenate class strings throw when a server page calls them.
This page is the complete inventory. It is also itself a Server Component, and the class strings and constant values further down were computed by it at build time.
Where the line is drawn
The package builds with tsup at bundle: false: every source file is transpiled to its own ESM module rather than merged into a few chunks. That is not a tree-shaking nicety, it is the reason the boundary works at all. Bundled output would push a "use client" line into the middle of a chunk, where it means nothing, and drag static primitives to the client with it.
The rule
A file's exports inherit that file's directive. Anything exported from a "use client" file reaches the server as a client reference — a component, a hook, a class builder, a string constant, all the same.
What it is not
It is not about what the code does. A pure function in a client file is still a client reference. And it is not about rendering: a Server Component may freely render <Dialog> or <Button>. Calling is the blocked verb, not rendering.
Because the cut is per file, one export can be moved to the safe side simply by moving it to a neighbouring file with no directive. That is exactly what every *-constants.ts module in the package is for.
Two failure modes
Crossing the boundary wrongly fails in two different ways, and telling them apart is most of what you need to know. Which one you get depends on whether the thing behind the boundary is a function or a value.
Loud — a function
Calling it throws at render, and names itself in the message:
Attempted to call buttonVariants() from the server but buttonVariants is on the client.
Annoying, but honest. The build stops and tells you the name to fix.
Silent — a value
Reading it gives you a client-reference proxy, so every property reads:
undefined
No error, no warning, and TypeScript still reports the literal type. This is the one that reaches production.
The trap: seven class builders that throw
These seven are the easiest thing in the package to trip over, precisely because nothing about them suggests a boundary. They take an options object and return a string. They touch no state, no DOM, no browser API. They fail anyway, because each one is declared in the same file as its client component.
| Name | Declared in | From a Server Component |
|---|---|---|
| buttonVariants | components/ui/button.tsx | Throws |
| toggleVariants | components/ui/toggle.tsx | Throws |
| fieldVariants | components/ui/field.tsx | Throws |
| inputGroupAddonVariants | components/ui/input-group.tsx | Throws |
| inputGroupButtonVariants | components/ui/input-group.tsx | Throws |
| progressTrackVariants | components/ui/progress.tsx | Throws |
| progressBarVariants | components/ui/progress.tsx | Throws |
// app/pricing/page.tsx — a Server Component. No 'use client' in this file.
import { buttonVariants } from '@comitor/ui'
export default function PricingPage() {
return (
<a href="/get-started" className={buttonVariants({ variant: 'outline' })}>
Get started
</a>
)
}
// Build fails:
// Attempted to call buttonVariants() from the server but buttonVariants is
// on the client.
//
// buttonVariants only concatenates strings. It fails anyway, because it is
// declared in components/ui/button.tsx, and that file starts with 'use client'.Two ways out
// The usual fix. A Server Component may RENDER a client component — that is the
// whole point of the boundary. It just may not CALL a function declared in a
// client file. Button takes asChild, so it hands its classes to your own <a>.
import { Button } from '@comitor/ui'
export default function PricingPage() {
return (
<Button asChild variant="outline">
<a href="/get-started">Get started</a>
</Button>
)
}// When you genuinely need the string, move the call across the boundary once.
// components/link-button.tsx
'use client'
import type { ComponentProps } from 'react'
import { buttonVariants, cn } from '@comitor/ui'
type LinkButtonProps = ComponentProps<'a'> & {
variant?: 'default' | 'outline' | 'ghost' | 'link'
size?: 'sm' | 'md' | 'lg'
}
export function LinkButton({ variant, size, className, ...props }: LinkButtonProps) {
return <a className={cn(buttonVariants({ variant, size }), className)} {...props} />
}
// app/pricing/page.tsx — still a Server Component
import { LinkButton } from '@/components/link-button'
export default function PricingPage() {
return <LinkButton href="/get-started" variant="outline">Get started</LinkButton>
}The nine that are fine
Learning that one class builder is stuck tells you nothing about the next one. Nine more, plus cn, live in files that carry no directive and are callable from a server page. There is no naming convention separating the two lists — this is the list.
| Name | Declared in | From a Server Component |
|---|---|---|
| alertVariants | components/ui/alert.tsx | Yes |
| badgeVariants | components/ui/badge.tsx | Yes |
| buttonGroupVariants | components/ui/button-group.tsx | Yes |
| cardVariants | components/ui/card.tsx | Yes |
| emptyMediaVariants | components/ui/empty.tsx | Yes |
| itemVariants | components/ui/item.tsx | Yes |
| itemMediaVariants | components/ui/item.tsx | Yes |
| navigationMenuTriggerStyle | components/ui/navigation-menu.tsx | Yes |
| statCardIconVariants | components/composite/stat-card.tsx | Yes |
| cn | lib/cn.ts | Yes |
Computed on the server
This page has no "use client". The four strings below were produced by calling the helpers during the server render of this route — they are printed, not transcribed.
- badgeVariants({ variant: 'success', size: 'sm' })
- inline-flex items-center font-medium rounded-md whitespace-nowrap gap-1 [&>svg]:size-3 [&>svg]:pointer-events-none bg-green/10 dark:bg-green/20 text-green-ink px-1.5 py-0.5 text-[10px]
- statCardIconVariants({ tone: 'primary' })
- flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/20 text-primary-ink
- itemMediaVariants({ variant: 'icon' })
- flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5 size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4
- cn('px-3 py-2 text-sm', 'px-4')
- py-2 text-sm px-4
The quiet one: data behind a client file
A constant re-exported through a "use client" file is not blocked — it simply arrives hollow. The RSC boundary cuts by file, so a data object crossing it becomes a client-reference proxy: every property reads undefined, an array reports a length of zero, and no diagnostic fires anywhere.
// The shape of the quiet bug. Same boundary, no error.
// The constant sits in the right module — and still arrives hollow, because the
// only path from the entry to it crosses a client file.
// chart/comitor-chart.tsx
'use client'
export { CHART_COLORS } from './chart-colors.js' // ← re-exported THROUGH a client file
// app/report/page.tsx — a Server Component
import { CHART_COLORS } from '@comitor/ui/chart'
CHART_COLORS.gold // undefined — not 'var(--chart-1)'
Object.keys(CHART_COLORS) // [] — the object arrives with no keys
// Nothing throws. Nothing warns. TypeScript still reports the literal type, so
// the editor stays green too. The chart renders with every series undefined.// What the package does instead. The constant lives in a directive-free module,
// and the barrel exports it STRAIGHT from there — routing it through the client
// file would put it back behind the boundary.
// shell/contrast-constants.ts — no directive, on purpose
export const CONTRAST_STORAGE_KEY = 'comitor-contrast'
// shell/index.ts
export * from './contrast-constants.js' // not via contrast-provider.tsx
export * from './contrast-provider.js'
// app/layout.tsx — a Server Component
import { CONTRAST_STORAGE_KEY } from '@comitor/ui/shell'
CONTRAST_STORAGE_KEY // 'comitor-contrast'Every pure constant in the package lives in a directive-free module for this reason: *-constants.ts beside each client component, chart/chart-colors.ts, shell/types.ts, shell/contrast-constants.ts, shell/display-axis-constants.ts, components/ui/tabs-variants.ts, and since 3.0.0 the language tables in i18n/messages.ts and i18n/locale.ts. Each barrel exports them straight from those files. These are the values a server layout or a server page actually reads — and the third column is what this page read, during its own server render:
| Read from a Server Component | Entry | Value |
|---|---|---|
| CHART_COLORS.gold | @comitor/ui/chart | 'var(--chart-1)' |
| CHART_SERIES_COLORS[0] | @comitor/ui/chart | 'var(--chart-1)' |
| DEFAULT_MARKDOWN_EDITOR_LABELS.editorLabel | @comitor/ui/editor | 'Editor' |
| CONTRAST_STORAGE_KEY | @comitor/ui/shell | 'comitor-contrast' |
| DENSITY_STORAGE_KEY | @comitor/ui/shell | 'comitor-density' |
| FONT_SIZE_STORAGE_KEY | @comitor/ui/shell | 'comitor-font-size' |
| CONTRAST_ATTRIBUTE | @comitor/ui/shell | 'data-contrast' |
| DISPLAY_PREF_STORAGE_KEYS.theme | @comitor/ui/shell | 'comitor-theme' |
| KEY_SEQUENCE_TIMEOUT_MS | @comitor/ui | 1200 |
| GANTT_SCALES.join(' · ') | @comitor/ui | 'day · week · month · quarter · year' |
| moveTreeItems([a, b], { ids: ['a'], parentId: 'b', index: 0, kind: 'indent' }).map((i) => `${i.id}→${i.parentId}`) | @comitor/ui | 'b→null · a→b' |
| LEGACY_AVATAR_TONE_ALIASES.navy | @comitor/ui | 'blue' |
| SELECT_EMPTY_VALUE | @comitor/ui | '__empty__' |
| VI_DATE_FORMAT | @comitor/ui | 'dd/MM/yyyy' |
| DEFAULT_PAGE_SIZE_OPTIONS[0] | @comitor/ui | 20 |
| DEFAULT_DATE_PRESETS.length | @comitor/ui | 3 |
| DEFAULT_DATE_RANGE_PRESETS.length | @comitor/ui | 5 |
| DEFAULT_DATA_TABLE_LABELS.emptyTitle | @comitor/ui | 'No data yet' |
| DEFAULT_TABLE_PAGINATION_LABELS.region | @comitor/ui | 'Pagination' |
| DEFAULT_COMITOR_LOCALE | @comitor/ui | 'en' |
| getComitorLanguage('vi-VN') | @comitor/ui | 'vi' |
| comitorMessagesVi.dataTable.emptyTitle | @comitor/ui | 'Chưa có dữ liệu' |
| getComitorMessages('vi').tablePagination.region | @comitor/ui | 'Phân trang' |
The storage keys matter most: a root layout is a Server Component, and it renders the anti-flash script for the contrast, density and text-size axes. If those three keys read undefined, the script writes to the wrong key and every display preference is silently forgotten on reload.
Hooks and toast commands
Nothing subtle here, and both groups fail loudly. All 26 hooks in the package are client-only — useShell and useIsMac as much as the stores of the components ported from Dice UI in 2.1.0 (useRating, useStepper, useTimePicker, usePhoneInput, useCropper, useKeyValueStore, useBanner, useBanners) and the two language hooks of 3.0.0 — as are the three imperative toast calls, toast, dismissToast and toastPromise. Called from a Server Component, each one throws with its own name in the message.
They are listed because a complete inventory is the point of this page, not because anybody expects them to work on a server render.
Language: the provider is client, the tables are not
Since 3.0.0 every default string in the package comes from one context, and it splits along the same per-file line as everything else here. ComitorLocaleProvider is a client component, and so are the two hooks that read it, useComitorLocale and useComitorMessages. A Server Component renders the provider — that is exactly what a root layout does, with a string locale — but cannot call the hooks.
The tables, and the functions that pick from them, are pure: comitorMessagesEn, comitorMessagesVi, COMITOR_MESSAGES, getComitorMessages(locale, override?), mergeComitorMessages, getComitorLanguage and DEFAULT_COMITOR_LOCALE. They are what a server render, a route handler or an email template reads to produce text in the user’s language with no React context to ask — the last four rows of the constants table above were read exactly that way.
// app/invoices/empty.tsx — a Server Component. No hook and no provider: the string
// tables are plain data, so the server picks the user's language itself.
import { getComitorMessages } from '@comitor/ui'
import { getLocale } from 'next-intl/server'
export default async function NoInvoices() {
const { dataTable } = getComitorMessages(await getLocale())
return <p>{dataTable.emptyTitle}</p> // 'No data yet' · 'Chưa có dữ liệu'
}
// The same call works in a route handler, a Server Action or an email template —
// anywhere text must be in the user's language and there is no React tree to
// hold a ComitorLocaleProvider.One rule is new in kind: a function cannot cross the boundary. Every key that puts a number into a sentence — members(count) in the shell table, the range and page lines of table pagination — is a function, because word order around a number changes with the language. A Server Component may call one; it may not pass one as a prop to a client component. So an override that contains a function, on the provider’s messages or on a component’s labels, is declared in a client file.
// ✗ app/layout.tsx — a Server Component. `members` is a function, and React refuses
// it at the boundary: "Functions cannot be passed directly to Client Components".
<ComitorLocaleProvider locale={locale} messages={{ shell: { members: (n) => `${n} seats` } }}>
// ✓ app/providers.tsx — the same override, declared on the client side.
'use client'
import { ComitorLocaleProvider, getComitorLanguage } from '@comitor/ui'
import type { ComitorLanguage, ComitorMessagesOverride } from '@comitor/ui'
// Module scope keeps the reference stable; one per language, because an override
// is written in one.
const SEATS: Record<ComitorLanguage, ComitorMessagesOverride> = {
en: { shell: { members: (count) => `${count} ${count === 1 ? 'seat' : 'seats'}` } },
vi: { shell: { members: (count) => `${count} chỗ` } },
}
export function Providers({ locale, children }: { locale: string; children: React.ReactNode }) {
return (
<ComitorLocaleProvider locale={locale} messages={SEATS[getComitorLanguage(locale)]}>
{children}
</ComitorLocaleProvider>
)
}
// …and the root layout passes only a string: <Providers locale={locale}>{children}</Providers>Reading the provider moved three components across the line: Spinner, Breadcrumb and Pagination carry "use client" since 3.0.0, because their accessible names now come from it. A server page still renders all three; the one thing it cannot do with a client component — hand it a function prop — never applies, since none of the three takes one. Pagination even gained: its links call buttonVariants, so while the file was directive-free, rendering PaginationLink straight from a server page hit the very error at the top of this page. RouteTabs went the other way on purpose: it takes LinkComponent — a component, so a function — from server layouts, so the file stays directive-free and only its <nav>, which needs the provider for its name, became an internal client leaf. And all 18 components ported from Dice UI in 2.1.0 are client components; their store hooks are in the Hooks group of the table below.
What is safe on a server page
The short version, before the full table.
Types
Every exported type and interface. Types are erased at build, so they never had a runtime side to be stuck on.
Tokens
@comitor/ui/tokens in full — the gold, ash and accent scales, semantic and status colours, layout and typography. tokens.ts has no directive and never will.
Constants
Every DEFAULT_* label set (the English tables, since 3.0.0), every storage key and attribute name, CHART_COLORS, VI_DATE_FORMAT, SELECT_EMPTY_VALUE, STATUS_TONES, MASK_PATTERNS, the Tabs class maps — and the language tables themselves, comitorMessagesEn and comitorMessagesVi.
Pure helpers
cn, the Vietnamese text utilities, the status helpers, the shell nav and app-launcher utilities, the three display-axis type guards, MaskInput’s masking engine, the data-grid formatters and filter helpers — and getComitorMessages, mergeComitorMessages and getComitorLanguage, for text in the user’s language.
Nine class builders
alertVariants, badgeVariants, buttonGroupVariants, cardVariants, emptyMediaVariants, itemVariants, itemMediaVariants, navigationMenuTriggerStyle, statCardIconVariants.
The brand marks and the seed script
ComitorLogo and ComitorLockup are static SVG with no directive, so a server page renders them without shipping any JavaScript. DisplayPrefsSeedScript (3.7.0) is directive-free too, and renders straight in the root layout.
Every callable name
Components are omitted: a Server Component can render any of them. What follows is everything you might call or read — built from the re-export graph of all nine entries, so it is the complete list rather than a selection.
| Name | Entry | Server? | Note |
|---|---|---|---|
Class builders (cva) The one group where the answer is genuinely mixed. Nothing in the name or the signature tells the two halves apart — only the directive on the file each one happens to live in. | |||
| buttonVariants | @comitor/ui | Throws | Ships beside the Button client component |
| toggleVariants | @comitor/ui | Throws | Ships beside Toggle |
| fieldVariants | @comitor/ui | Throws | Ships beside Field |
| inputGroupAddonVariants | @comitor/ui | Throws | Ships beside InputGroup |
| inputGroupButtonVariants | @comitor/ui | Throws | Ships beside InputGroup |
| progressTrackVariants | @comitor/ui | Throws | Ships beside Progress |
| progressBarVariants | @comitor/ui | Throws | Ships beside Progress |
| alertVariants | @comitor/ui | Yes | alert.tsx carries no directive |
| badgeVariants | @comitor/ui | Yes | badge.tsx carries no directive |
| buttonGroupVariants | @comitor/ui | Yes | button-group.tsx carries no directive |
| cardVariants | @comitor/ui | Yes | card.tsx carries no directive |
| emptyMediaVariants | @comitor/ui | Yes | empty.tsx carries no directive |
| itemVariants | @comitor/ui | Yes | item.tsx carries no directive |
| itemMediaVariants | @comitor/ui | Yes | item.tsx carries no directive |
| navigationMenuTriggerStyle | @comitor/ui | Yes | navigation-menu.tsx carries no directive |
| statCardIconVariants | @comitor/ui | Yes | stat-card.tsx carries no directive |
Hooks Every hook in the package is client-only, and every one of them throws loudly. No surprises here — this group is listed for completeness. | |||
| useIsMac | @comitor/ui | Throws | Reads navigator after mount |
| useKeySequences | @comitor/ui | Throws | Global key-sequence listener — 3.7.0 |
| useCarousel | @comitor/ui | Throws | Embla carousel context |
| useComitorLocale | @comitor/ui | Throws | The language context: locale, language, messages, dateFnsLocale |
| useComitorMessages | @comitor/ui | Throws | One namespace of the provider’s string table |
| useRating | @comitor/ui | Throws | Rating’s store — 2.1.0, from Dice UI |
| useStepper | @comitor/ui | Throws | Stepper’s store — 2.1.0, from Dice UI |
| useTimePicker | @comitor/ui | Throws | TimePicker’s store — 2.1.0, from Dice UI |
| usePhoneInput | @comitor/ui | Throws | PhoneInput’s store — 2.1.0, from Dice UI |
| useCropper | @comitor/ui | Throws | Cropper’s store — 2.1.0, from Dice UI |
| useKeyValueStore | @comitor/ui | Throws | KeyValue’s store — 2.1.0, from Dice UI |
| useBanner | @comitor/ui | Throws | One Banner’s own context — 2.1.0, from Dice UI |
| useBanners | @comitor/ui | Throws | The banner queue: add, remove, clear — 2.1.0 |
| useColorPicker | @comitor/ui | Throws | ColorPicker’s store — 3.15.0, from Dice UI |
| useShell | @comitor/ui/shell | Throws | Shell context |
| useContrast | @comitor/ui/shell | Throws | Contrast axis context |
| useDensity | @comitor/ui/shell | Throws | Density axis context |
| useFontSize | @comitor/ui/shell | Throws | Text-size axis context |
| usePalette | @comitor/ui/shell | Throws | Theme-palette axis context — 3.15.0 |
| useChart | @comitor/ui/chart | Throws | ChartContainer config context |
| useDataGrid | @comitor/ui/data-grid | Throws | The grid’s table instance and handlers |
| useDataGridMessages | @comitor/ui/data-grid | Throws | The grid’s string table in force |
| useDataGridPresence | @comitor/ui/data-grid | Throws | Who else is on a cell |
| useDataGridUndoRedo | @comitor/ui/data-grid | Throws | Undo and redo history |
| useFormField | @comitor/ui/form | Throws | react-hook-form field context |
| useFileUpload | @comitor/ui/uploader | Throws | Builds an Uppy instance and wires its events |
Toast commands Imperative browser calls. They were never going to work from a server render, and they say so. | |||
| toast | @comitor/ui | Throws | Sonner command |
| dismissToast | @comitor/ui | Throws | Sonner command |
| toastPromise | @comitor/ui | Throws | Sonner command |
Helper functions All pure, all in directive-free modules, all callable from a Server Component — and from a Server Action, and from a route handler. One exception, at the end of the group. | |||
| cn | @comitor/ui | Yes | clsx + tailwind-merge |
| getComitorMessages | @comitor/ui | Yes | A locale’s whole string table, plus an optional override — the provider’s work, without React |
| mergeComitorMessages | @comitor/ui | Yes | Merges an override into a table, namespace by namespace |
| getComitorLanguage | @comitor/ui | Yes | Locale → built-in table: vi-anything is vi, the rest en |
| dateFormatForLocale | @comitor/ui | Yes | Ships with VI_DATE_FORMAT |
| toSelectValue | @comitor/ui | Yes | Ships with SELECT_EMPTY_VALUE |
| fromSelectValue | @comitor/ui | Yes | Ships with SELECT_EMPTY_VALUE |
| applyMask | @comitor/ui | Yes | MaskInput’s masking engine — 2.1.0 |
| applyCurrencyMask | @comitor/ui | Yes | MaskInput’s masking engine |
| applyPercentageMask | @comitor/ui | Yes | MaskInput’s masking engine |
| getUnmaskedValue | @comitor/ui | Yes | MaskInput’s masking engine |
| toUnmaskedIndex | @comitor/ui | Yes | MaskInput caret mapping |
| fromUnmaskedIndex | @comitor/ui | Yes | MaskInput caret mapping |
| getAvatarToneClasses | @comitor/ui | Yes | Deterministic tone from a name |
| getAvatarToneColors | @comitor/ui · @comitor/ui/tokens | Yes | Hex pair for canvas and PDF work |
| getAvatarToneIndex | @comitor/ui · @comitor/ui/tokens | Yes | Deterministic tone from a name |
| getStatusConfig | @comitor/ui | Yes | Status map lookup |
| getStatusClasses | @comitor/ui | Yes | Status map lookup |
| getStatusLabel | @comitor/ui | Yes | Status map lookup |
| sortByStatusPriority | @comitor/ui | Yes | Status map ordering |
| getVietnameseInitials | @comitor/ui · @comitor/ui/shell | Yes | Same function, two entries |
| normalizeVi | @comitor/ui · @comitor/ui/shell | Yes | Same function, two entries |
| removeVietnameseTones | @comitor/ui | Yes | String utility |
| matchesSearch | @comitor/ui | Yes | Tone-insensitive search predicate |
| truncate | @comitor/ui | Yes | String utility |
| toNavGroups | @comitor/ui/shell | Yes | Nav shape normaliser |
| flattenNavItems | @comitor/ui/shell | Yes | Nav shape normaliser |
| toSearchParams | @comitor/ui/shell | Yes | Accepts the searchParams object of a server page |
| isNavItemActive | @comitor/ui/shell | Yes | Active-route matching |
| isNavBranchActive | @comitor/ui/shell | Yes | Active-route matching |
| getEntitledApps | @comitor/ui/shell | Yes | App launcher filtering |
| getLockedApps | @comitor/ui/shell | Yes | App launcher filtering |
| resolveCurrentApp | @comitor/ui/shell | Yes | App launcher lookup |
| appAccentStyle | @comitor/ui/shell | Yes | Inline style object for the app accent |
| isContrastMode | @comitor/ui/shell | Yes | Type guard |
| isDensityMode | @comitor/ui/shell | Yes | Type guard |
| isFontSizeMode | @comitor/ui/shell | Yes | Type guard |
| isThemePalette | @comitor/ui/shell | Yes | Type guard — 3.15.0 |
| parseDisplayPrefsSeed | @comitor/ui/shell | Yes | Filters any value to valid display defaults — 3.7.0 |
| advanceKeySequence · normalizeSequenceKey | @comitor/ui | Yes | key-sequence.ts, the rules under useKeySequences — 3.7.0 |
| isEditableTarget | @comitor/ui | Yes | Same file, safe to import; it reads the DOM, so call it in the browser |
| moveTreeItems · computeTreeRollup · computeTreeNumbering · groupTreeChildren | @comitor/ui | Yes | tree-view-constants.ts — apply a TreeView move in a Server Action before writing it — 3.12.0 |
| formatDate · formatDateForDisplay | @comitor/ui/data-grid | Yes | Take an optional date-fns locale; without one, date-fns’ en-US format (3.0.0) |
| formatDateToString · parseLocalDate | @comitor/ui/data-grid | Yes | Date ⇄ the stored yyyy-MM-dd |
| formatFileSize · generateFileId | @comitor/ui/data-grid | Yes | File cells |
| getCellKey · parseCellKey | @comitor/ui/data-grid | Yes | Row and column ⇄ cell key |
| getColumnVariant · getEmptyCellValue | @comitor/ui/data-grid | Yes | Per cell variant: icon and label, empty value |
| getRowHeightValue · getLineCount | @comitor/ui/data-grid | Yes | Row height → pixels, lines |
| getDataGridMessages | @comitor/ui/data-grid | Yes | One grid language’s string table |
| getDefaultOperator · getOperatorsForVariant · getFilterFn | @comitor/ui/data-grid | Yes | Filter operators and the filter function |
| getDataGridSelectColumn | @comitor/ui/data-grid | Throws | The exception: declared in a client file. It builds the checkbox column, so call it where columns are defined — in a client component |
Constants The group that would fail silently if it were placed wrongly. Each of these is declared in a module with no directive and exported straight from the barrel, so a server render reads the real value. Since 3.0.0 every DEFAULT_*_LABELS holds the English table, and no component reads it any more — they read ComitorLocaleProvider. | |||
| comitorMessagesEn | @comitor/ui | Yes | i18n/messages.ts — the English table, which defines every shape |
| comitorMessagesVi | @comitor/ui | Yes | i18n/messages.ts — the Vietnamese table |
| COMITOR_MESSAGES | @comitor/ui | Yes | i18n/messages.ts — both, keyed by language |
| DEFAULT_COMITOR_LOCALE | @comitor/ui | Yes | i18n/locale.ts |
| CHART_COLORS | @comitor/ui/chart | Yes | chart/chart-colors.ts |
| CHART_SERIES_COLORS | @comitor/ui/chart | Yes | chart/chart-colors.ts |
| CONTRAST_MODES | @comitor/ui/shell | Yes | shell/contrast-constants.ts |
| CONTRAST_ATTRIBUTE | @comitor/ui/shell | Yes | shell/contrast-constants.ts |
| CONTRAST_STORAGE_KEY | @comitor/ui/shell | Yes | Read by the root layout |
| DEFAULT_CONTRAST_TOGGLE_LABELS | @comitor/ui/shell | Yes | shell/contrast-constants.ts |
| DENSITY_MODES | @comitor/ui/shell | Yes | shell/display-axis-constants.ts |
| DENSITY_ATTRIBUTE | @comitor/ui/shell | Yes | shell/display-axis-constants.ts |
| DENSITY_STORAGE_KEY | @comitor/ui/shell | Yes | Read by the root layout |
| DEFAULT_DENSITY | @comitor/ui/shell | Yes | shell/display-axis-constants.ts |
| FONT_SIZE_MODES | @comitor/ui/shell | Yes | shell/display-axis-constants.ts |
| FONT_SIZE_ATTRIBUTE | @comitor/ui/shell | Yes | shell/display-axis-constants.ts |
| FONT_SIZE_STORAGE_KEY | @comitor/ui/shell | Yes | Read by the root layout |
| DEFAULT_FONT_SIZE | @comitor/ui/shell | Yes | shell/display-axis-constants.ts |
| THEME_PALETTES | @comitor/ui/shell | Yes | shell/display-axis-constants.ts — 3.15.0 |
| PALETTE_ATTRIBUTE | @comitor/ui/shell | Yes | shell/display-axis-constants.ts — 3.15.0 |
| PALETTE_STORAGE_KEY | @comitor/ui/shell | Yes | Read by the root layout — 3.15.0 |
| DEFAULT_PALETTE | @comitor/ui/shell | Yes | shell/display-axis-constants.ts — 3.15.0 |
| DEFAULT_SHELL_LABELS | @comitor/ui/shell | Yes | shell/types.ts |
| DEFAULT_COMMAND_PALETTE_LABELS | @comitor/ui | Yes | command-palette-constants.ts |
| DEFAULT_DATA_TABLE_LABELS | @comitor/ui | Yes | data-table-constants.ts |
| DEFAULT_DATE_PICKER_LABELS | @comitor/ui | Yes | date-picker-constants.ts |
| DEFAULT_DATE_PRESETS | @comitor/ui | Yes | date-picker-constants.ts |
| DEFAULT_DATE_RANGE_PRESETS | @comitor/ui | Yes | date-picker-constants.ts |
| VI_DATE_FORMAT | @comitor/ui | Yes | date-picker-constants.ts |
| DEFAULT_SEARCH_FILTER_BAR_LABELS | @comitor/ui | Yes | search-filter-bar-constants.tsx |
| DEFAULT_TABLE_PAGINATION_LABELS | @comitor/ui | Yes | table-pagination-constants.ts |
| DEFAULT_PAGE_SIZE_OPTIONS | @comitor/ui | Yes | table-pagination-constants.ts |
| SELECT_EMPTY_VALUE | @comitor/ui | Yes | form-field-constants.ts |
| DEFAULT_ACTION_BAR_LABELS | @comitor/ui | Yes | action-bar-constants.ts |
| DEFAULT_CROPPER_LABELS | @comitor/ui | Yes | cropper-constants.ts |
| DEFAULT_KANBAN_LABELS | @comitor/ui | Yes | kanban-constants.ts |
| DEFAULT_KEY_VALUE_LABELS | @comitor/ui | Yes | key-value-constants.ts |
| DEFAULT_PHONE_INPUT_LABELS | @comitor/ui | Yes | phone-input-constants.ts |
| DEFAULT_STEPPER_LABELS | @comitor/ui | Yes | stepper-constants.ts |
| DEFAULT_TIME_PICKER_LABELS | @comitor/ui | Yes | time-picker-constants.ts |
| DEFAULT_TOUR_LABELS | @comitor/ui | Yes | tour-constants.ts |
| MASK_PATTERNS | @comitor/ui | Yes | mask-input-constants.ts |
| KEY_SEQUENCE_TIMEOUT_MS | @comitor/ui | Yes | key-sequence.ts — 3.7.0 |
| DISPLAY_PREF_VALUES · DISPLAY_PREF_STORAGE_KEYS | @comitor/ui/shell | Yes | shell/display-prefs-seed.tsx — 3.7.0 |
| DEFAULT_FILE_UPLOAD_LABELS | @comitor/ui/uploader | Yes | uploader/file-upload-constants.ts |
| DEFAULT_IMAGE_UPLOAD_FIELD_LABELS | @comitor/ui/uploader | Yes | Same file. The barrel imports it from there, not through the client field |
| DEFAULT_MARKDOWN_EDITOR_LABELS | @comitor/ui/editor | Yes | editor/markdown-editor-constants.ts — imports no @tiptap package, so reading it pulls no editor in |
| TEXT_ · NUMBER_ · DATE_ · SELECT_ · BOOLEAN_FILTER_OPERATORS | @comitor/ui/data-grid | Yes | data-grid/filters.ts |
| DATA_GRID_MESSAGES · DEFAULT_DATA_GRID_LOCALE | @comitor/ui/data-grid | Yes | data-grid/messages.ts |
| dataGridMessages · dataGridMessagesEn | @comitor/ui/data-grid | Yes | data-grid/messages-en.ts |
| dataGridMessagesVi | @comitor/ui/data-grid | Yes | data-grid/messages-vi.ts |
| STATUS_TONES | @comitor/ui | Yes | status-config.ts |
| AVATAR_TONE_NAMES | @comitor/ui | Yes | lib/avatar-tone.ts |
| LEGACY_AVATAR_TONE_ALIASES | @comitor/ui | Yes | lib/avatar-tone.ts — navy, ash, neutral → blue, purple, pink; exported since 3.12.1 |
| GANTT_SCALES | @comitor/ui | Yes | gantt-constants.ts — 3.12.0 |
| tabsListVariants | @comitor/ui | Yes | tabs-variants.ts — a plain map, not cva |
| tabsTriggerVariants | @comitor/ui | Yes | tabs-variants.ts — a plain map, not cva |
| TABS_TRIGGER_BASE | @comitor/ui | Yes | tabs-variants.ts |
| gold · themePalettes · brandNeutral · ash · accent · accentInk · accentForeground · semantic · highContrast · status · chart · layout · typography | @comitor/ui · @comitor/ui/tokens | Yes | The whole token mirror. tokens.ts has no directive and never will |
Brand marks Both are deliberately directive-free so a server page renders static SVG and ships no JavaScript for them. | |||
| ComitorLogo | @comitor/ui/shell | Yes | Static SVG, no directive |
| ComitorLockup | @comitor/ui/shell | Yes | Static SVG, no directive |
Seed script Directive-free for the same reason: it belongs in the root layout, which is a Server Component. | |||
| DisplayPrefsSeedScript | @comitor/ui/shell | Yes | Inline script that seeds account display defaults before first paint — 3.7.0 |
How the list stays true
A silent boundary bug cannot be caught by review, so the package gates it before publish. Three checks run on every release.
The directive survives the build
Every source file that starts with "use client" must produce a dist file that starts with it too. A dropped directive would make a client component look like a server one, and the failure would only show up in a consuming app after publish.
No pure constant sits in a client file
A data value declared inside a "use client" file fails the gate outright. The gate knows data by its initialiser — an object, array, string, number or boolean literal — so components written as cva(…) or forwardRef(…) are exempt: their initialiser is a call, not data. The same rule is why, since 3.0.0, every DEFAULT_*_LABELS is written { ...xEn } rather than = xEn: assigned straight from an identifier, the constants the gate could see fell from 58 to 47, and nothing turned red.
No constant reaches an entry through a client file
The stricter one. A constant can sit correctly in its own pure module and still be routed to the barrel via a client re-export, which reproduces the bug exactly. The gate walks the re-export graph from all nine entries and fails any name whose only path crosses a client file.
Check 3 exists because check 2 was not enough, and that was measured rather than assumed. Put CHART_COLORS in its own pure module — correct on its own, so checks 1 and 2 both stay green — but let the barrel reach it through the client chart file, and a real Next build hands a server page an object with zero keys again.