Overview
Open tasks
248
+12vs last week
Closed this week
96
+18%vs last week
Recent activity
What the team changed today.
Dana Whitfield closed ·
Hà Vũ assigned you ·
Tomas Berg commented on ·
Grace Okafor opened ·
Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.
Patterns
A complete product surface assembled from the package: the Tier 3 application frame with its workspace notices and first-run tour, a page header and container, KPI cards, a members table with search, sorting, selection, bulk actions and paging, tabbed content, create and edit forms, a stepped setup flow, and settings — split across tabs that are real routes, or kept on one long page with its own table of contents.
Nothing on this page is a drawing of a component. Every demo is the real export, wired to real state — the sidebar navigates, the table sorts, the filters filter, the notices queue, the tour walks the frame, and the confirmation dialogs actually remove rows.
AppShell is the whole frame: sidebar, header, workspace switcher, product launcher, user menu, mobile menu and the ⌘K palette slot, around a scrolling <main>. You pass data once at the layout; every part reads it back from the shell context. Two things live in the frame rather than in any page: workspace notices, queued with Banners, and a first-run Tour.
Open tasks
248
+12vs last week
Closed this week
96
+18%vs last week
What the team changed today.
Dana Whitfield closed ·
Hà Vũ assigned you ·
Tomas Berg commented on ·
Grace Okafor opened ·
Move around the sidebar and the screen changes. Switch workspace at the top of the sidebar, switch product from the launcher in the header — the two move independently, and the notice above the page follows the workspace. The search field opens the ⌘K palette; CRM is locked, so choosing it calls onUpsell instead of navigating.
The notice above the page follows the workspace, not the route. Acme Corp has two — a declined card and the weekend’s maintenance — and Banners shows the more urgent one; close it and the next slides in. Northwind only has the maintenance, and a notice you closed stays closed when you come back. Notices go in through useBanners().onBannerAdd, once per workspace: they are data that follows the workspace rather than markup in the layout, and the queue owns each one until someone closes it. Declaring <Banner>s inside <Banners> works too — they keep their place when the layout re-renders.
The tour starts from the card on Overview, from md up, and makes four stops. It moves focus into each step, keeps Tab there, ends on Escape or a click outside, and hands focus back to the button that started it. Create workspace in the switcher opens the flow from Workspace Setup below: finish it and the new workspace joins the switcher — start a trial and it brings a notice of its own.
The package draws every string in English — visible text, accessible names, screen-reader-only text, drag-and-drop announcements — until one ComitorLocaleProvider says otherwise. It belongs in the root layout, above the shell: sign-in, error pages and anything portalled out of the frame render outside AppShell, and they must speak its language. A Comitor app takes the language from the user’s settings through next-intl; an app with one language writes locale="vi". The rest is in Set the language.
// app/layout.tsx — the ROOT layout, a server component
import type { ReactNode } from 'react'
import { NextIntlClientProvider } from 'next-intl'
import { getLocale } from 'next-intl/server'
import { ComitorLocaleProvider } from '@comitor/ui'
import { ThemeProvider } from '@comitor/ui/shell'
import './globals.css' // @import "tailwindcss"; @import "@comitor/ui/styles.css";
/**
* Every string the package draws — visible text, aria-labels, sr-only text,
* drag-and-drop announcements — is English until ONE provider says otherwise,
* and this is where it goes:
*
* - The ROOT, not the shell layout. Sign-in, error pages and anything portalled
* out of the frame render outside AppShell, and must speak its language.
* - A Comitor app never hard-codes the language: it is the USER's setting
* (Account profile → cookie → next-intl), so the provider takes getLocale().
* An app with one language writes locale="vi" and needs no next-intl.
* - locale is BCP 47. It picks the built-in table (vi → Vietnamese, anything
* else → English) AND drives Intl: numbers, times, the 12/24-hour clock.
*/
export default async function RootLayout({ children }: { children: ReactNode }) {
const locale = await getLocale()
return (
<html lang={locale} suppressHydrationWarning>
<body>
<NextIntlClientProvider>
<ThemeProvider>
<ComitorLocaleProvider locale={locale}>{children}</ComitorLocaleProvider>
</ThemeProvider>
</NextIntlClientProvider>
</body>
</html>
)
}
// app/locale-provider.tsx — only once you pass FUNCTIONS. The root layout then
// renders <AppLocaleProvider locale={locale}> where ComitorLocaleProvider was.
'use client'
import type { ReactNode } from 'react'
import { ComitorLocaleProvider, type ComitorMessagesOverride } from '@comitor/ui'
import { enGB } from 'date-fns/locale'
/**
* A string that counts is a function (members(3) → "3 people"), and a date-fns
* locale object is mostly functions. Neither crosses from a server component
* into a client one, so the provider that receives them sits in a client file.
* Module-level on purpose: a new messages object every render re-renders every
* component that reads the provider.
*/
const MESSAGES: ComitorMessagesOverride = {
shell: {
workspace: 'Company',
members: (count) => `${count} ${count === 1 ? 'person' : 'people'}`,
},
}
export function AppLocaleProvider({ locale, children }: { locale: string; children: ReactNode }) {
return (
<ComitorLocaleProvider
locale={locale}
messages={MESSAGES}
// date-fns dates map only vi → vi; every other code gets en-US (12/25/2026).
// British dates need their locale passed in.
dateFnsLocale={locale === 'en-GB' ? enGB : undefined}
>
{children}
</ComitorLocaleProvider>
)
}AppShell also accepts locale. It is a shortcut for an app with no root layout of its own: it wraps only the frame, in a fresh provider that does not inherit messages or dateFnsLocale from an outer one, and adding or removing the prop remounts the whole shell. Region is a separate setting again: PhoneInput takes defaultCountry and MaskInput takes currency from the workspace, never from the language.
Three zones — identity, a centred search pill that opens the command palette, and the action cluster. The notification panel is deliberately not in the package: only the bell is, because the payload of a notification differs per product.
Open the bell for the app-owned panel, the grid for the product launcher, the avatar for the user menu, or click the search field to raise the command palette.
StatCard carries the label, the figure, an icon tone, a delta and an optional footer. Colour comes from direction times polarity, so “overdue is up” reads red while “spend is down” reads green. Flip the switch to see the loading skeleton the package ships.
Open tasks
248
+12vs last week
Closed this week
96
+18%vs last week
Overdue
7
+3vs last week
Spend to date
$89.4K
-4.1%vs budget
The list screen every product needs: SearchFilterBar + DataTable + TablePagination, with a column toggle, row selection, bulk actions in an ActionBar, a confirmation behind ConfirmDialog and two different empty states. Search, sort, filter, select and remove rows — all of it is live.
| Actions | ||||||
|---|---|---|---|---|---|---|
| Bình Phan[email protected] | Owner | Active | 14 | |||
| Dana Whitfield[email protected] | Admin | Active | 9 | |||
| Hà Vũ[email protected] | Admin | Active | 11 | |||
| Grace Okafor[email protected] | Admin | Active | 8 | |||
| Sara Lindqvist[email protected] | Member | Active | 7 |
Select a row and the bar rises: it counts the selection, resends invitations or removes members, and its close button — or Escape — clears the selection. An Escape that already closed a menu or the confirmation is not counted twice, and neither is one pressed while a Vietnamese input method is still composing a word. In an app the bar floats at the bottom of the viewport and portals to the end of <body>, so a keyboard user reaches it last — and it centres on the viewport, half the sidebar’s width off the table’s centre. Portal it into an empty <div> right after the table and pass anchor="container" to fix both. This demo passes portalContainer to keep it inside the box and next in the Tab order.
A page header with a primary action over a tabbed list of resource cards. These panels live in one document, so this is Tabs — the moment a tab becomes its own URL, it is RouteTabs instead.
Everything the workspace is currently shipping.
BIL · 18 open · due 12 Sep 2026
ONB · 26 open · due 05 Sep 2026
DS · 5 open · due 19 Sep 2026
A short create flow in a ResponsiveDialog — a centred dialog from 768px up, a bottom drawer below it, in reach of a thumb and dismissed with a swipe. Laid out with FormSection and FormField: spread the control argument into the input and label, description, error text and required state are wired for you.
The submit button stays disabled until name and email are valid. Narrow the window below 768px and it opens as a drawer.
The same fields in a side sheet — better for editing a record you want to keep in view, and it has room for a long form without the scrolling body a small dialog forces on you.
The drawer keeps its draft between opens — edit the role, save, then reopen it.
A create flow too long for one screen: Stepper in a ResponsiveDialog, with TagsInput for the people to invite. The shell only asks for it — the switcher shows Create workspace once onCreateWorkspace exists — and everything after that is the app’s.
Press Next with the name empty. Then type a Vietnamese name and watch the address follow it — “acme” is already taken.
Moving forward runs one validation, whether it came from Next or from a later step in the list — the list is a tablist, so the arrow keys walk it too. Back and Next stay the same two buttons from the first step to the last: StepperPrev and StepperNext disable themselves at the ends, and a focused button that is disabled or removed loses the keyboard user’s focus. The address comes from normalizeVi, so “Công ty Bình Minh” becomes cong-ty-binh-minh.
Four settings sections behind RouteTabs: real links carrying aria-current="page", drawn from the same class table as Tabs so the two are indistinguishable on screen. The header and the strip belong in the settings layout, not in each page. Billing measures its quotas with CircularProgress, whose arc turns to the warning ink from 80%.
Preferences for Acme Corp.
Everyone in Acme Corp sees this.
Prefixes every task id.
Current route: /settings
When every setting belongs to one object — here, a webhook — one long page reads better than five tabs. ScrollSpy keeps an in-page table of contents in step with a fixed-height scroll area, and the custom headers are a KeyValue list: paste a few Header: value lines into a name field and they split into rows.
Acme Corp · a POST to your endpoint whenever work changes.
Where each delivery goes.
A paused webhook keeps its settings and receives nothing.
Only what you subscribe to is sent.
Someone files a task in any project.
A task moves to Done.
Anyone comments on a task.
An invitation goes out from Members.
Sent with every delivery. Paste Header: value lines into a name field to add several at once.
Each delivery carries X-Comitor-Signature, an HMAC-SHA256 of the body. Verify it before trusting a payload.
What happens when your endpoint does not answer 2xx in time.
Five more attempts over 24 hours, further apart each time.
2 headers · 2 of 4 events
ScrollSpyLink cancels the native #fragment jump, so Tab would carry on from the table of contents rather than from the section you jumped to — each section takes tabIndex={-1} and its link focuses it. KeyValue keeps its default trim: the field shows the space in “Bearer token” as it is typed, and every header it hands back is already trimmed. Below sm the table of contents is hidden and the sections read in order.
Comitor navigation has two perpendicular axes: workspace and product. Here the frame runs layout="header-first", so the workspace switcher moves into the header and the launcher sits beside it. A product outside the plan shows a padlock and calls onUpsell rather than landing the reader on a 403.
The two axes are perpendicular: changing workspace keeps you in the same product, and changing product keeps you in the same workspace. Chat carries its own accent, so the sidebar highlight and the icon tints follow the product you are in.
Open the grid icon in the header for the product launcher, and the workspace name on its left for the workspace list.
Do
AppShell once, in the layout, and let the parts read the context.ComitorLocaleProvider in the root layout fed the user’s locale — not with labels on every component, and not only around the shell.ConfirmDialog, single-row deletes included.Don't
DataTable out of <table> — you lose sorting, selection, pinned columns, skeletons and the empty state.ActionBar is a named toolbar with roving focus, and its Escape leaves the layers above it alone.Tabs when each tab is a URL: the roles it announces describe a panel swap that is not happening.LinkComponent on Next — every tab and nav item becomes a full page load.md the sidebar and the launcher live in the mobile menu.<header>, <aside> with a labelled <nav>, and the scrolling <main>, with a skip link to it as the first thing Tab reaches (3.7.0) — do not draw a second one. A screen with a second navigation landmark — the ScrollSpy table of contents is one — names each of them.aria-label, and every icon-only row action one naming the record it acts on.LetterAvatar already carries the name as screen-reader text — mark it aria-hidden when a visible name sits next to it, or the name is read twice.Progress bar is a mute picture; name it after the thing it measures. So is a CircularProgress: aria-label names the ring, and getValueText makes it say “24 of 30 seats” where the centre shows 80%.aria-label that says something the screen does not fails WCAG 2.5.3, Label in Name.ActionBar is a toolbar named “Bulk actions”: the arrow keys move between its items, its close button says what it does (“Clear selection”), and Escape clears the selection — unless a menu or dialog above it has already used that Escape.role="dialog" named by its title and described by its text. Focus moves in, Tab stays in, Escape or a click outside ends the tour, and focus goes back to the button that started it.role="status" live regions, read out politely as they arrive; their close button is named for you (“Close notification”).KeyValue names each remove button after its row (“Remove Authorization”), sends focus to the new row on Add and to the next remove button after a delete. ScrollSpy marks the link for the section in view with aria-current="location".