@adocommercekit/admin publishes an Inertia + React management panel into your application at
/admin/commerce. It is built from coss ui primitives (Base UI + Tailwind CSS v4) and Bklit UI
chart components, both vendored into the package at pinned registry revisions and copied into your
source tree by configure().
The panel is experimental at first publication. @adocommercekit/admin-api, which it depends on,
is beta. See stability tiers.
What it manages
| Page | Route | Abilities |
|---|---|---|
| Dashboard | /admin/commerce |
commerce.metrics.view |
| Orders | /admin/commerce/orders |
commerce.orders.view |
| Order detail | /admin/commerce/orders/:id |
per action |
| Products | /admin/commerce/products |
commerce.products.view |
| Product create | /admin/commerce/products/new |
commerce.products.manage |
| Product edit | /admin/commerce/products/:id |
commerce.products.manage |
| Inventory | /admin/commerce/inventory |
commerce.inventory.* |
| Customers | /admin/commerce/customers |
commerce.customers.view |
| Customer detail | /admin/commerce/customers/:id |
commerce.customers.* |
| Audit log | /admin/commerce/audit |
commerce.audit.view |
That list is closed. Extending it is a specification change, not a pull request — admin panels expand to consume projects, and this is the structural brake.
Everything the panel can do, the admin REST API can do, with the same ability and the same audit entry. The panel is a consumer of that service layer, never a second implementation.
Install
The panel needs a configured core, a configured @adocommercekit/admin-api, and a configured
@adonisjs/inertia with the React adapter.
pnpm add @adocommercekit/adminIts UI runtime dependencies are your direct dependencies, because the vendored components live in
your source tree. configure() refuses to publish until they are present and prints the exact install
line, which today is:
pnpm add @base-ui/react @daypicker/react @number-flow/react \
@visx/curve @visx/event @visx/gradient @visx/grid @visx/group \
@visx/pattern @visx/responsive @visx/scale @visx/shape \
class-variance-authority clsx d3-array d3-shape lucide-react motion \
tailwind-merge tailwindcss @tailwindcss/vite zod
pnpm add -D @types/d3-array @types/d3-shapeThen:
node ace configure @adocommercekit/admin
node ace migration:run
node ace commerce:doctorconfigure() performs no network access. The vendored component tree ships inside the package
tarball, so the command works offline.
Two manual steps remain, because both files are yours and a codemod that rewrote them would be a worse trade than a printed instruction:
-
Add the Tailwind v4 plugin to
vite.config.ts:import tailwindcss from '@tailwindcss/vite' export default defineConfig({ plugins: [react(), tailwindcss() /* … */], }) -
Map the abilities in
app/policies/commerce/*. Until you do, every panel route answers 403 — see abilities and policies.commerce:doctorfails while the shipped role resolver still returns nothing.
commerce:doctor covers every panel obligation: the routes preload file, commerceAdminAuth on the
route group, one ability per route, no CSRF exemption, the Tailwind plugin, the dashboard page, the
host dependency set, and the vendored-file provenance hashes.
What lands in your app
app/controllers/commerce_admin_panel/* Inertia controllers, one per page group
app/services/commerce_admin_panel.ts shared props, ability resolution, mutation seam
app/validators/commerce_admin_panel.ts panel-only request shapes
start/routes/commerce_admin_panel.ts the route table, with abilities and rate limits
inertia/css/commerce_admin.css the Tailwind v4 token layer
inertia/lib/commerce/* money, dates, abilities, query-string, i18n catalog
inertia/components/commerce/ui/* vendored coss primitives
inertia/components/commerce/charts/* vendored Bklit chart components
inertia/components/commerce/* composed tables, dialogs, shell
inertia/pages/admin/commerce/** the pagesEvery one of those files is yours: baselined, diffable, and upgradeable through commerce:upgrade.
Exclude the two vendored directories from your formatter and linter. They are copied
verbatim from pinned registry revisions and content-hashed, so reformatting them breaks
commerce:doctor’s divergence tripwire and turns every future upgrade into a conflict:
// eslint.config.js
export default [
{ ignores: ['inertia/components/commerce/ui/**', 'inertia/components/commerce/charts/**'] },
...configApp(),
]# .prettierignore
inertia/components/commerce/ui
inertia/components/commerce/chartsThe authored part of the tree follows the React/coss convention its primitives use —
kebab-case component files and the framework-agnostic Link from @inertiajs/react — so
an AdonisJS host also wants @unicorn/filename-case and
@adonisjs/prefer-adonisjs-inertia-link turned off for inertia/components/commerce/**,
inertia/lib/commerce/**, and inertia/pages/admin/commerce/**. The playground’s
eslint.config.js shows the complete shape.
Customizing
Published extensions are the only sanctioned way for packages to add to the panel; the page inventory
is closed to runtime mutation. A package’s configure() publishes pages and components into the host
tree plus owned nav.<pkg>.ts and i18n.<pkg>.ts fragments beside the host’s nav.ts and i18n.ts.
A two-line codemod wires each host file with an import and spread. The fragments carry their own
publication baselines; the wiring lines ride the existing three-way merge, so the next upgrade reports
an expected conflict on nav.ts and i18n.ts that you resolve once through .commercekit/incoming/.
Extension pages gate only on existing CommerceAbility values: packages cannot mint abilities, and the
ability seam remains deferred per ADR-006. commerce:doctor flags unreferenced fragments, dangling
imports, duplicate nav ids, out-of-catalog abilities, and colliding i18n keys. Tabs and every other
in-page surface remain closed.
Operationally: pnpm add <pkg> → node ace configure <pkg> → the nav entry and page appear for actors
granted the gating ability. To remove an extension, delete its fragments and the two wiring lines;
commerce:doctor flags any dangling state.
Strings all resolve through inertia/lib/commerce/i18n.ts. English ships. Add a sibling catalog
and switch activeCatalog to translate. The key set is part of the publication baseline, so
commerce:upgrade tells you which keys a new engine version added.
Composed components (inertia/components/commerce/* outside ui/ and charts/) are the place to
change behaviour. They are deliberately separated from the vendored primitives, which you are unlikely
to want to edit and which upgrade cleanly as long as you do not. The shared vocabulary every surface
is built from is worth knowing before you add to it:
| File | What it owns |
|---|---|
panel.tsx |
The one content container. Panel, PanelHeader/Title/Description/Actions/Body/Footer. |
page-header.tsx |
Page title, description, meta line, action cluster. |
filter-bar.tsx |
The list-page toolbar: SearchFilter, SelectFilter, MultiSelectFilter, ToggleFilter, removable active-filter chips. |
date-range-picker.tsx |
DateRangePicker / DatePicker over Popover + Calendar. The panel has no input type="date". |
table/data-table.tsx |
DataTable + DataTablePagination: sorting, sticky header, responsive column dropping, skeleton loading, cursor pagination. |
metric.tsx |
MetricStrip — one framed row of measures with deltas, instead of a grid of metric cards. |
money.tsx |
Money display and MoneyInput over NumberField, both on minor-unit strings. |
empty-state.tsx |
EmptyState, ErrorState, TableEmptyRow. Distinguishes “no records” from “no matches”. |
confirm-dialog.tsx |
ConfirmDialog (an AlertDialog, for a decision) and FormDialog (a Dialog, for a mutation that collects input). |
status-badge.tsx |
humanizeState plus the order/payment/shipment/product tone maps. |
theme-toggle.tsx |
usePanelTheme and the light/dark/system menu. |
lib/commerce/format.ts |
initials, relativeTime, addressLines, abilityLabel. |
Two rules hold across all of them. No native form control: every enumeration is a Select,
Combobox, or ToggleGroup, every date is the composed picker, every quantity is a NumberField.
And money never becomes a float — amounts stay minor-unit strings from the wire to the input and
back, through lib/commerce/money.ts.
Theming is CSS custom properties in inertia/css/commerce_admin.css, all OKLCH — the published
tree contains no hex literal. The layer uses @import 'tailwindcss' source(none) plus explicit
@source rules, so it scans only the panel tree and cannot collide with an existing host CSS setup.
Three things about it are load-bearing:
- The full coss token set is defined for both themes, plus the twenty
--chart-*chrome tokens the vendored Bklit tree reads (--chart-grid,--chart-crosshair,--chart-tooltip-background,--chart-label, the marker and segment tokens). Those have no upstream fallbacks: undefined, the grid, crosshair and tooltips resolve to invalid paint and simply disappear. - The five series hues are identical in light and dark; only lightness and chroma move. A series that changes hue with the theme is a data-integrity bug, not a palette.
- The isolation guard at the end of the file is not decoration. Cascade layers lose to unlayered
declarations, so a host stylesheet with a global
footer { background: … }orbutton { color: inherit }beats every Tailwind utility coss relies on. The guard reverts unlayered host declarations for every element the panel emits, inside the panel and inside Base UI portals. If you add an element type to the panel, add it to that list.
Dark mode is applied by adding commerce-admin-dark to document.documentElement — not to the shell
root, because overlays portal to document.body and would otherwise stay light.
Adding a capability means adding it to the service layer and the REST surface first, then consuming it from a controller. A panel controller that grows business logic beyond validation, authorization, and transformation is a bug.
The vendored-file story
coss and Bklit are shadcn-style registries: their components are copied into projects, not installed
as versioned runtime packages. So this package vendors them at build time and records
inertia/components/commerce/registry_provenance.json, which pins for every file:
- the source registry and item name,
- the upstream repository and commit,
- the retrieval date and license,
- the hash of the upstream content and of the vendored copy.
Vendored files are modified in exactly one way: registry import aliases (@/registry/default/ui/…)
become relative paths, so the published tree resolves without any host alias configuration. Nothing
else changes, which is why the two hashes are both worth recording.
Three consequences worth knowing:
- Upstream cannot break you. A breaking revision or a re-license upstream has no effect on a
shipped host. Re-vendoring is an explicit engine-release decision, and
commerce:upgradereports each re-vendored file with its upstream attribution. - You may re-pull components yourself with
npx shadcn@latest add @coss/… @bklit/….commerce:upgradethen classifies those files as host-modified and leaves them alone — the standard sovereignty trade.commerce:doctorwarns that they diverge from the recorded hashes, because it cannot distinguish your edit from tampering; that warning is a supply-chain tripwire, not an error. configure()never installs anything. It prints thepnpm addline and aborts.
Upgrading
node ace commerce:upgradeAlongside the usual add/update/conflict plan, the panel contributes four report categories:
vendored:re-vendored, new, or dropped UI components, with upstream attribution and the previous hash;i18n:catalog keys this engine version added;nav:navigation items this engine version added;ability:abilities this engine version added, with a reminder to extend your policy mappings.
Files you have edited are never overwritten. commerce:upgrade stages a three-way merge under
.commercekit/incoming/ and tells you where.
How the panel talks to the server
Exactly two data paths, by design:
- Inertia visits. Page loads and every mutation. Mutations submit to the panel’s own routes and
answer with a redirect, so the page re-renders with fresh server state. A state-machine refusal —
the REST surface’s
409 E_COMMERCE_INVALID_TRANSITION— becomes a reload plus an error toast, with the current legal transitions rendered, never a silent failure. - JSON fetches to
/api/commerce/admin. The dashboard charts refetch when you change the range, and the ⌘K palette looks up orders, products, and customers. Both go through the REST surface, so they are subject to the same abilities and the same rate-limit buckets.
Action buttons on order, payment, and shipment detail are rendered from the state machine: the server computes the legal transitions for the current state and the page renders only those.
Security notes
- Session auth with CSRF for the whole panel. No panel route is CSRF-exempt.
- Deny by default: an unmapped ability is a 403, never a pass.
- Panel mutations share the REST mutation rate-limit bucket, so scripting the UI cannot bypass API limits.
- Shared props carry the actor’s resolved ability set so the UI can hide forbidden actions. That is UX; the middleware is the enforcement.
- Serialization goes through
forAdmintransformer variants only. Provider secrets, encrypted columns, raw provider payloads, and customer cart tokens never reach the panel. - Audit entries are append-only, written in the same transaction as their effect, and contain ids, states, and amounts — never names, emails, or addresses.
- Shield’s default reaction to a missing CSRF token is a flash-and-redirect, which is right for
an HTML form and wrong for a JSON API. The playground’s
app/exceptions/handler.tsmapsE_BAD_CSRF_TOKENunder/api/commerce/onto the documented error envelope with a 403; copy that if your host exposes the REST surface to non-browser clients. The Inertia panel needs no such mapping — Inertia carries the token and Adonis redirects it back correctly.