Shell
App Launcher
Axis 2 of the shell: the 3×3 grid button in the header that swaps product without leaving the workspace. Tiles the workspace has not licensed still appear — with a padlock, and a callback instead of a destination.
Why this is not one flat menu
A Comitor user belongs to many workspaces, and each workspace has many products enabled. The two are perpendicular — switching workspace does not switch product, and switching product does not switch workspace — so a single list would have to enumerate the cross product of both. The shell keeps them as two controls that read the same context:
Axis 1 — workspace
Changes the data context: Acme Corp ▸ Beta Ltd. Lives in WorkspaceSwitcher.
Axis 2 — app
Changes the product inside one workspace: Tasks ▸ Chat ▸ CRM. That is this page.
Neither control takes its data through props. Both read useShell(), so the app list is handed to ShellProvider (or AppShell) once, and AppLauncher can then be dropped anywhere in the tree. Outside a provider useShell() throws rather than rendering an empty launcher, which is why every demo below is wrapped in one. The four display axes — light/dark, palette, density, font size — are a separate set entirely; they live at Display Axes.
The launcher
Six apps, two of them unlicensed. The header is a stand-in for AppHeader, which mounts this control at the right end by default.
Open the grid button, then walk the tiles with the arrow keys. Picking an app moves the header — the accent, the name and the icon fill all follow the app you chose. Nothing leaves the page: the demo supplies its own LinkComponent.
- pathname
- /tasks
- currentApp
- tasks
- onUpsell
- not called yet
Two layouts, one panel
AppLauncherPanel is the panel on its own, split out so MobileMenu can embed it without a second popover inside an already-open sheet. On desktop it is the 3-column grid. Inside the 20rem mobile sheet it becomes a list: that sheet is narrower than the popover the grid was measured for, and it shares one scroll area with the current app’s own menu — which is the thing people opened the sheet to reach. Same reason MobileMenu puts the app list after that menu rather than above it, inverting the desktop order, where the launcher lives in the header and costs the sidebar nothing.
grid — 22rem popover
list — inside MobileMenu
- In the grid a long name wraps to two lines and then clamps —
line-clamp-2 break-words, withauto-rows-fron the grid so a taller row does not leave its neighbours short.break-wordsis not belt-and-braces: without it a name containing no space never reaches a third line, so the ellipsis never appears and the text is cut flat mid-letter instead. - In the list a name truncates to one line, because a row that can be one or two lines tall makes the whole column ripple.
- The tile text column carries
w-fullin the grid, not justmin-w-0: a grid tile isflex-col items-start, so that column sizes to its content and grows past the tile — measured at 272px inside a 109px tile — and nothing inside it ever has a width to clamp against. The list variant is immune; its row is horizontal, soflex-1has already pinned the width.
Locked apps
An AppDescriptor with entitled: false is still rendered — under a second caption, with a grey icon well and a padlock badge — and clicking it calls onUpsell(app.id) instead of going anywhere. The panel splits the list with getEntitledApps and getLockedApps; the demo list above splits 4 / 2. A group with nothing in it is not rendered at all, so a fully licensed workspace never sees a “Discover more” heading.
A locked tile is not a disabled control
It is clickable and it does something, so it gets none of the WCAG 1.4.3 / 1.4.11 exemption that disabled components enjoy. An earlier build dimmed it to opacity-60, which put the description at 2.41:1 (light) / 2.74:1 (dark) and the icon at 2.31:1 / 2.60:1 — legible only on hover, i.e. only for people using a mouse. The opacity is gone: the description sits at --muted-foreground for 5.23:1 / 5.35:1, and the icon on --muted for 4.89:1 / 4.86:1. Nothing was lost from the signal: the well is still grey instead of the app’s colour, the padlock is still there, and the accessible name still ends in “— Locked”.
import { ChartColumn, SquareCheckBig } from 'lucide-react'
import { AppShell, getEntitledApps, getLockedApps } from '@comitor/ui/shell'
import type { AppDescriptor } from '@comitor/ui/shell'
const apps: AppDescriptor[] = [
{ id: 'tasks', name: 'Tasks', icon: SquareCheckBig, href: '/tasks' },
// Omitted or true = entitled. Only an explicit false locks a tile.
{ id: 'analytics', name: 'Analytics', icon: ChartColumn, href: '/analytics', entitled: false },
]
getEntitledApps(apps) // -> [tasks] (rendered under labels.myApps)
getLockedApps(apps) // -> [analytics] (rendered under labels.discoverApps)
<AppShell
apps={apps}
// Fired instead of navigating when someone clicks a locked tile. The shell
// has no opinion about what an upsell looks like — route, open a dialog,
// fire an analytics event, whatever the product does.
onUpsell={(appId) => router.push(`/billing?app=${appId}`)}
>
{children}
</AppShell>Which app is open
The current tile is marked aria-current="page" and tinted, and the whole frame inherits that app’s accent. Which app that is comes from currentAppId when you pass it, and otherwise from resolveCurrentApp. Longest path prefix wins, so an app mounted at / cannot swallow /crm/deals, and the query is stripped from each href first — a router pathname never carries one, so /crm?view=board would otherwise never match anything. The table is computed live from the demo app list, whose CRM entry does exactly that:
| pathname | resolveCurrentApp(...) | why |
|---|---|---|
| /tasks | tasks | exact path match |
| /tasks/projects/42 | tasks | longest prefix match |
| /crm | crm | exact path match |
| /crm/deals/9 | crm | longest prefix match |
| /analytics | analytics | exact path match |
| /settings/profile | tasks | no match — falls back to apps[0] |
Identity-coloured tiles
Since 1.10.0 the launcher paints each tile's icon with an IconAvatar, seeded on the app's id. The old model made every app wait to be hand-assigned a colour, so the sixth product either collided with another or sat colourless until someone edited a table; now a new app has a colour the moment it exists — and the same colour in an email, on a canvas or in a PDF, because getAvatarToneColors reads the same tone table.
Pass tone on the AppDescriptor to pin one of the eight identity tones by hand; omit it and the colour is derived from id. The seed is the id, not the name: a display name changes with the interface language, and hashing it would flip an app's colour on vi ⇄ en. A locked tile stays grey through a className, not a tone — “not unlocked” is a state, and the tone table is identity colour.
accent / accentInk / accentForeground are deprecated
As of 1.10.0 the launcher no longer reads the three colour fields on AppDescriptor. They still compile and still drive the frame accent through appAccentStyle (the next section), so nothing breaks — but an app's tile colours will move on upgrade, on purpose. To hold a specific hue, set tone to that tone's name; to migrate, drop the three fields and pass tone or nothing.
import { SquareCheckBig, MessageSquare } from 'lucide-react'
import type { AppDescriptor } from '@comitor/ui/shell'
const apps: AppDescriptor[] = [
// Pin a tile colour by hand — one of the eight AVATAR_TONE_NAMES.
{ id: 'tasks', name: 'Tasks', icon: SquareCheckBig, href: '/tasks', tone: 'teal' },
// Or omit tone and let it derive from id — a new app is coloured for free.
{ id: 'chat', name: 'Chat', icon: MessageSquare, href: '/chat' },
]
// accent / accentForeground / accentInk are deprecated as of 1.10.0: the launcher
// ignores them. They still drive the FRAME accent through appAccentStyle, so keep
// them only where you rely on that.The per-app accent
Each app may carry its own colour. appAccentStyle turns that into an inline style — never a generated class name, which Tailwind could not have compiled anyway — and the shell puts it on the frame root, on Sidebar and on MobileMenu. Everything below inherits it through CSS custom properties. (The launcher tile left this model in 1.10.0 — it is an IconAvatar now, coloured by identity; see the section above.)
Tasks
var(--color-teal)
Chat
appAccentStyle → undefined
Customer Relations
var(--color-navy)
There are three variables, not one, and that is the whole reason accentInk exists as a separate field instead of being derived:
--app-accentonly ever fills a background — a solidbg-app-accentor a /5–/15 tint.text-app-accentandborder-app-accentare wrong by definition, even though Tailwind generates them.--app-accent-foregroundis what sits on that solid fill.--app-accent-inkis the same colour when it goes onto the page — text, standalone icons, borders, the active marker inSidebarMenuItem, the check mark inSelect, the open-row rule inDataTable.
The two steps pull in opposite directions: the better a colour fills, the worse the same colour reads as text on white. The default gold makes the case — as a fill it is fine, but as text #E8B824 measures 1.86:1 on the page background and 1.69:1 on its own 15% tint, while the ink step #7A5800 reaches 6.51:1. The package deliberately does not derive one from the other with color-mix: every hue needs a different factor, and a derived number is a number nobody measured. Set accent and forget accentInk and every ink-coloured surface silently keeps the theme’s gold — the wrong colour, at a passing contrast ratio, which is exactly the failure no linter catches.
The same arithmetic governs the Tier-2 components that still use this accent: StatCard, PageHeader, DataTable and FilterChips fill a background with bg-app-accent, while StatCard and Select put the colour on the page with text-app-accent-ink. The launcher tile used to be one more consumer — a solid bg-app-accent well, chosen over the gold-on-gold-tint of 0.1.0 that measured 1.69:1 — but as of 1.10.0 it is an IconAvatar instead, coloured from the app's identity rather than its accent.
Link or callback
An unlocked tile is a real link unless you give the shell an onAppSelect, in which case it becomes a button. Links are the default because middle-click, cmd-click and “open in new tab” are free and a screen reader announces the right role; the callback exists because moving between products often is not a route change at all — a different subdomain, a prefetch, a socket to tear down. Links go through ShellNavLink, which uses the LinkComponent from context — next/link by default, your own router adapter outside Next.
// No onAppSelect -> every unlocked tile is a real link through
// LinkComponent (next/link by default). Middle-click, cmd-click and
// "open in new tab" all work, and a screen reader announces a link.
<AppShell apps={apps}>{children}</AppShell>
// With onAppSelect -> tiles become buttons and the app owns the transition:
// swap subdomain, prefetch, warm a cache, close a socket.
<AppShell apps={apps} onAppSelect={(app) => router.push(app.href)}>
{children}
</AppShell>Keyboard
| Key | Does |
|---|---|
⌘/Ctrl + Shift + K | Toggle the launcher. Owned by ShellProvider, and checked before ⌘K so the two do not collide on the same letter. |
Alt + 1…9 | Jump to the nth entitled app via onAppSelect. Reads event.code, because on macOS Alt+1 produces “¡”. |
← → ↑ ↓ | Move between tiles. Both groups are treated as one continuous grid, so the arrow keys cross from “My apps” into “Discover more”. |
Home / End | First and last tile. |
Enter / Space | Left to the underlying button or link — the panel never intercepts them. |
Escape | Closes the launcher, the mobile menu and the command palette together. |
The roving navigation finds its tiles by querying the DOM for [data-app-tile] rather than keeping an array of refs. That marker is the reason it keeps working when an app supplies its own LinkComponent: an unlocked tile may render as your router’s link component, and there is no guarantee that component forwards a ref. A DOM attribute survives whatever it is rendered as, and the arrow keys then just call .focus() on the element they found. The index is clamped, so ↓ on the last row lands on the last tile instead of losing focus.
The demos on this page pass enableShortcuts={false} so a documentation page does not take ⌘K and ⌘⇧K away from the site around it. Arrow-key navigation is local to the panel and stays live.
Labels
Every string in Tier 3 defaults to Vietnamese, screen-reader-only ones included. ShellLabels has 33 keys and is spread over DEFAULT_SHELL_LABELS, so overriding a handful is enough. These six are what the launcher reads.
| Prop | Type | Default | Description |
|---|---|---|---|
apps | string | 'Ứng dụng' | Tooltip on the trigger button — visible text. |
appLauncherLabel | string | 'Mở danh sách ứng dụng' | aria-label of that same trigger. Must contain the apps string, or voice control cannot address the button. |
myApps | string | 'Ứng dụng của bạn' | Caption over the entitled group. |
discoverApps | string | 'Khám phá thêm' | Caption over the locked group. |
locked | string | 'Chưa mở khoá' | Appended to a locked tile’s accessible name as "{name} — {locked}". Screen-reader only. |
lockedHint | string | 'Ứng dụng chưa có trong gói hiện tại' | title attribute on a locked tile — the hover tooltip. |
AppLauncher props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'grid' | 'list' | 'grid' | 'grid' is the 3-column tile layout used in the header popover. 'list' is one app per row, used inside MobileMenu where the sheet is only 20rem wide. |
inline | boolean | false | Drop the trigger button and the popover and render the panel directly. Equivalent to using AppLauncherPanel, kept so a caller can flip between the two shapes with one prop. |
align | 'start' | 'center' | 'end' | 'end' | Popover alignment against the trigger. Ignored when inline. The trigger sits at the right end of AppHeader, so end keeps the panel on screen. |
onSelected | () => void | — | Called after any tile is activated, including a locked one. The popover already closes itself; this is for the surrounding surface — MobileMenu passes its own close. |
className | string | — | Applied to the trigger button, or to the panel when inline. |
AppLauncherPanel props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'grid' | 'list' | 'grid' | Same two layouts. It also sets the keyboard grid width: 3 columns for grid, 1 for list, which is what ArrowUp / ArrowDown step by. |
onSelected | () => void | — | Called after a tile is activated. Use it to close whatever is hosting the panel. |
className | string | — | Applied to the panel root, the flex column that holds both groups. |
AppDescriptor
The data contract for one product. The package ships no app data of its own — this is a type, and the array is yours.
| Prop | Type | Default | Description |
|---|---|---|---|
idrequired | string | — | Stable key. Also what onUpsell receives, and what currentAppId matches against. |
namerequired | string | — | Display name. In the grid it wraps to two lines then clamps; in the list it truncates to one. |
hrefrequired | string | — | Destination. May carry a query — resolveCurrentApp strips it before matching the pathname. |
iconrequired | ShellIcon | — | A LucideIcon, or any component that accepts className so the shell can size it. |
description | string | — | A line under the name. Clamped to two lines in the grid; in the list it is left unclamped, because that row is already as wide as the panel. |
tone | AvatarToneName | string | — | Added in 1.10.0. The launcher tile is an IconAvatar, and tone pins one of the eight identity tones (AVATAR_TONE_NAMES). Omit it and the colour is derived from id. An unknown name falls back to the derived colour rather than throwing. |
accent | string | — | Deprecated (1.10.0): the launcher no longer reads it — use tone. Still valid for the FRAME accent: any CSS colour, ideally a var(), that appAccentStyle turns into --app-accent, which only ever FILLS a background. Declaring it still obliges you to declare accentInk too. |
accentForeground | string | — | Deprecated (1.10.0). Text and icons sitting on the solid accent fill. Becomes --app-accent-foreground; defaults to the theme value. |
accentInk | string | — | Deprecated (1.10.0). The same colour on the page background — StatCard, Select, DataTable, PageHeader and FilterChips still read it as --app-accent-ink; only the launcher stopped. |
entitled | boolean | true | Only an explicit false locks the app. Locked tiles render under labels.discoverApps with a padlock and call onUpsell instead of navigating. |
badge | number | string | — | Count or short string next to the name, rendered as a destructive Badge. |
Helpers
Pure functions — no hooks, no 'use client' — so they run in a Server Component too. The shell calls all four internally; they are exported because an app that builds its own frame needs the same answers.
| Prop | Type | Default | Description |
|---|---|---|---|
resolveCurrentApp | (apps, pathname) => AppDescriptor | null | — | Longest matching path prefix, query stripped from each href first. Falls back to apps[0] when nothing matches, and to null only when the list is empty. ShellProvider calls it whenever currentAppId is absent. |
getEntitledApps | (apps) => AppDescriptor[] | — | Apps whose entitled is not false, in the order they were given. Drives the "My apps" group and the Alt+1…9 shortcut ordering. |
getLockedApps | (apps) => AppDescriptor[] | — | Apps with entitled: false. Drives the "Discover more" group; when it is empty the second group is not rendered at all. |
appAccentStyle | (app) => CSSProperties | undefined | — | Inline style carrying whichever of --app-accent, --app-accent-foreground and --app-accent-ink the app declared. undefined when it declared none, so the theme default survives. |
ShellProvider props that drive the launcher
A subset of ShellProviderProps — AppShell accepts every one of them and passes them straight through.
| Prop | Type | Default | Description |
|---|---|---|---|
apps | AppDescriptor[] | [] | The whole ecosystem, locked apps included. AppLauncher renders nothing when this is empty. |
currentAppId | string | — | Skip inference and name the open app outright. Without it ShellProvider runs resolveCurrentApp against the pathname. |
onAppSelect | (app: AppDescriptor) => void | — | Present, and unlocked tiles become buttons that call it. Absent, and they are links through LinkComponent. Also what Alt+1…9 fires. |
onUpsell | (appId: string) => void | — | Called when a locked tile is activated. Nothing navigates. |
enableShortcuts | boolean | true | Owns Cmd/Ctrl+Shift+K (toggle the launcher), Escape (close it) and Alt+1…9. Set false in docs, Storybook and kiosk builds. |
labels | Partial<ShellLabels> | DEFAULT_SHELL_LABELS | Spread over the Vietnamese defaults, so a partial override is enough. |
Accessibility
- The trigger is a real
<button>wrapped in a RadixPopoverTrigger, so it carriesaria-haspopup,aria-expandedandaria-controlswithout the shell writing them, and focus returns to it when the panel closes. - Its accessible name is
labels.appLauncherLabelwhile the visible tooltip islabels.apps. When you translate, keep the short visible string a substring of the long accessible one — otherwise “click Apps” in voice control matches nothing (WCAG 2.5.3, Label in Name). An Englishlabelsobject over the Vietnamese visible text breaks the same criterion, which is why the demos override both. - A locked tile is a
<button>named`${app.name} — ${labels.locked}`withlabels.lockedHintas itstitle. Neveraria-disabled: it is operable, and it is the only route to the upgrade flow. - Locked state is carried three ways — a grey icon well instead of the app colour, a padlock badge, and the accessible name — so it is never colour alone (WCAG 1.4.1). No opacity is used anywhere on the tile, for the contrast reasons measured above.
- The open app’s tile is marked
aria-current="page"as well as tinted, on both the link and the button rendering. - Arrow keys move focus; Enter and Space are never intercepted, so each tile behaves like the native button or link it is. Focus is moved by looking up
[data-app-tile]in the DOM, which keeps working with a customLinkComponentthat does not forward refs. - “My apps” and “Discover more” are visible captions on plain
<section>elements, not ARIA groups — they organise the panel visually, and each tile’s own accessible name carries everything a screen reader needs on its own. - The badge is a real
Badgewith visible text, announced in reading order right after the app name. It also carriesshrink-0, though not to stop a long name squeezing it: measured either way the badge is the same width, becauseBadgeis alreadywhitespace-nowrapand all the shrink lands on the name column instead. It is there so this call site matches every otherBadgein the shell, and so the guard stops depending on a class inside a component someone else may change.
Related
Workspace Switcher
Axis 1 — the other half of the pair, changing which data you are looking at rather than which product.
App Shell
ShellProvider, useShell and the frame that hands this control its app list.
App Header
Where the launcher lives by default, and the showAppLauncher prop that removes it.
Sidebar Nav
Navigation inside one app, and MobileMenu, which embeds the list variant of this panel.