@cboxdk/cbox-ui 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  Shared UI for the Cbox app family — id, cortex, atlas, track, and whatever's next.
4
4
 
5
- This package gives every Cbox app the same chrome (top bar, org switcher, app launcher, user menu, notifications bell) and the same page primitives (panels, headers, tabs, status pills) so cross-app navigation feels like one product. It also ships the design tokens (colours, fonts, radii) and the brand assets in one place.
5
+ This package gives every Cbox app the same shell (`CboxShell`: rail, header with the context selectors, ⌘K palette, Help column, app switcher, account menu, phone layout) and the same page primitives (panels, headers, tabs, status pills), so moving between apps feels like one product. It also ships the design tokens (colours, fonts, radii) and the brand assets in one place.
6
6
 
7
- The package is **headless about data**: every component reads identity, organization, and notification state from a `<CboxIdProvider>` context that the consuming app populates from the future `@cboxdk/cbox-id-client` SDK. Components never fetch — they render what the host gave them.
7
+ The shell renders one Inertia prop, `shell`, which the server builds (laravel-console-kit). The prop carries only the current selection and URLs; menus, the app catalog, Help and search load in the browser after the page has painted, from same-origin endpoints the app serves. See [The shell](#the-shell) and the spec in `docs/specs/2026-11-10-shell-v1.md`.
8
8
 
9
9
  ## Install
10
10
 
@@ -29,61 +29,40 @@ The package is published on npm as `@cboxdk/cbox-ui` under the MIT License. The
29
29
 
30
30
  That's it — every `text-foreground`, `bg-card`, `border-border`, `text-mono`, etc. utility now resolves against the Cbox palette. Light + dark variants come bundled: dark applies under `.dark` or `data-theme="dark"` on the root, whichever the host writes. Add `@source '../../node_modules/@cboxdk/cbox-ui/dist';` so Tailwind generates the utilities the components use, and when the host stores its theme choice as an attribute, point Tailwind's `dark:` variant at it (`@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));`).
31
31
 
32
- ### 2. Wrap the app root with `<CboxIdProvider>`
33
-
34
- In **id itself** (which IS the source of truth), populate from Inertia shared props:
32
+ ### 2. Render the shell in the persistent layout
35
33
 
36
34
  ```tsx
37
- import { CboxIdProvider } from '@cboxdk/cbox-ui';
35
+ // resources/js/layouts/AppLayout.tsx
36
+ import { router, usePage, Link } from '@inertiajs/react';
37
+ import { CboxShell, type Shell } from '@cboxdk/cbox-ui';
38
38
 
39
- export default function AppRoot({ shared, children }) {
39
+ export default function AppLayout({ children }: { children: React.ReactNode }) {
40
+ const { shell } = usePage<{ shell: Shell }>().props;
40
41
  return (
41
- <CboxIdProvider value={shared.cboxId}>
42
+ <CboxShell
43
+ shell={shell}
44
+ LinkComponent={Link}
45
+ navigate={(url, o) => (o?.newTab ? window.open(url, '_blank', 'noopener') : router.visit(url, { method: o?.method, data: o?.data }))}
46
+ onPreferenceChange={(key, value) => router.post('/settings/shell', { [key]: value }, { preserveState: true, preserveScroll: true })}
47
+ >
42
48
  {children}
43
- </CboxIdProvider>
49
+ </CboxShell>
44
50
  );
45
51
  }
46
- ```
47
-
48
- In **cortex / atlas / track / future apps**, populate from the id-client SDK on session start:
49
52
 
50
- ```tsx
51
- import { CboxIdProvider } from '@cboxdk/cbox-ui';
52
- import { useIdSession } from '@cboxdk/cbox-id-client';
53
-
54
- export default function AppRoot({ children }) {
55
- const session = useIdSession(); // {user, organizations, currentOrg, ...}
56
- return (
57
- <CboxIdProvider value={session.cboxIdContext}>
58
- {children}
59
- </CboxIdProvider>
60
- );
61
- }
53
+ // Every page: `Page.layout = page => <AppLayout>{page}</AppLayout>`, so the rail, Help and the
54
+ // chat stay mounted between visits.
62
55
  ```
63
56
 
64
- The context shape is exported as `CboxIdContextValue` — see `src/context/cbox-id.tsx` for the full surface. Required keys:
65
-
66
- - `user` — `{id, name, email, avatar_url?, locale?, timezone?}`
67
- - `currentOrganization` — current org context (or null on user-only surfaces)
68
- - `organizations` — every org the user belongs to (drives the switcher)
69
- - `apps` — sister apps for the launcher
70
- - `urls` — `{idBaseUrl, accountUrl, orgSettingsUrl, switchOrgUrl, signOutUrl}`
71
- - `notifications?` — `{unreadCount, inboxUrl}` (optional, hides the bell when missing)
72
- - `onSwitchOrganization?` — callback when the user picks an org (default: form-POST to `urls.switchOrgUrl`)
73
-
74
- ### 3. Drop in the chrome
75
-
76
- ```tsx
77
- import { TopBar } from '@cboxdk/cbox-ui';
57
+ Set the theme before the first paint: the server writes `class="dark"` and `data-theme` on
58
+ `<html>` from `shell.preferences.theme`, or the head carries the inline script
59
+ `themeBootScript(theme)` returns (it resolves `system` from the device without saving it).
60
+ Copy `public/brand/` (the Cbox wordmark: `cbox-logo.png`, `cbox-logo-white.png` and their `@2x`)
61
+ as before; `brandAssetBase` changes the path.
78
62
 
79
- <TopBar
80
- brand={<MyAppLogo />}
81
- centre={<MySearchBar />}
82
- showOrgSwitcher
83
- />
84
- ```
63
+ ### 3. Identity context (inbox only)
85
64
 
86
- The default right-cluster is `<AppLauncher>` → `<NotificationsBell>` → `<UserMenu>`. Override with `rightCluster={...}` if a particular app needs a different mix.
65
+ `<CboxIdProvider>` still feeds the inbox surface (`CboxSurfaces`). The shell does not read it.
87
66
 
88
67
  ### 4. Use page primitives
89
68
 
@@ -106,16 +85,15 @@ Match id's existing patterns — see id/resources/js/pages/* for examples.
106
85
 
107
86
  ## Components
108
87
 
109
- ### Chrome (cross-app navigation)
88
+ ### Shell
110
89
 
111
- | Component | What it does |
90
+ | Export | What it does |
112
91
  |---|---|
113
- | `TopBar` | Standard 56px-tall app bar. Slot for brand + centre + right cluster. |
114
- | `OrgSwitcher` | Dropdown listing the user's orgs. Calls `onSwitchOrganization` (or POSTs to `urls.switchOrgUrl`) on pick. |
115
- | `AppLauncher` | 9-dot grid showing sister Cbox apps the user can jump into. |
116
- | `UserMenu` | Avatar dropdown with "Manage account ↗" + "Team & billing ↗" (deep-links into id) + Sign out. Pass `LinkComponent` when those rows are pages of the host app, so the visit stays client-side. |
117
- | `CboxAppCatalog` | The Switch-app control fed live from the host's catalog endpoint: the shared `<cbox-app-switcher>` element (see "App catalog" below). |
118
- | `NotificationsBell` | Bell icon with unread badge, links to id's unified inbox. Renders nothing if context lacks `notifications`. |
92
+ | `CboxShell` | The one shell for every app, rendered from the `shell` prop. See [The shell](#the-shell). |
93
+ | `Shell`, `ContextLevel`, `ContextItem`, `NavArea`, `PaletteAction`, … | The prop's TypeScript contract (spec §11.1). laravel-console-kit produces it. |
94
+ | `useShellHelp()` | `toggleHelp`, `openArticle`, `ask` for a page's own `?` button. |
95
+ | `applyTheme`, `themeBootScript` | Put a theme choice on the document the way the tokens read it. |
96
+ | `CboxAppCatalog` | The framework-free `<cbox-app-switcher>` element in React. Kept for apps that have not moved to the shell yet. |
119
97
 
120
98
  ### Page primitives
121
99
 
@@ -175,7 +153,7 @@ no provider is present. The host owns the selected language and its catalogs:
175
153
 
176
154
  ```tsx
177
155
  <CboxLocalizationProvider locale={language} translate={t}>
178
- <CboxAppShell {...shellProps}>{children}</CboxAppShell>
156
+ <CboxShell shell={shell}>{children}</CboxShell>
179
157
  </CboxLocalizationProvider>
180
158
  ```
181
159
 
@@ -193,6 +171,8 @@ callback; without it, existing browser formatting is preserved.
193
171
 
194
172
  ### App catalog (Switch app)
195
173
 
174
+ The shell's own app grid reads the same endpoint (see [The shell](#the-shell)). The element below stays for apps that have not moved to the shell yet.
175
+
196
176
  Every Cbox app, Blade or React, draws the same Switch-app control: the `<cbox-app-switcher>`
197
177
  custom element from `@cboxdk/cbox-ui/app-switcher`. It has no React dependency and renders
198
178
  in a shadow root with its own styles, so no app stylesheet (a reset, a missing preflight, a
@@ -270,30 +250,132 @@ In React, `<CboxServiceMark appKey="infra" />` and the collapsed `CboxBrand` rea
270
250
  kept catalog; `CboxAppIcon` renders any single app's icon, and a host that loads the catalog
271
251
  itself calls `rememberAppCatalog(apps)`.
272
252
 
273
- ### Workspace navigation (current design contract)
274
-
275
- `CboxWorkspaceShell` is the two-tier product shell. Pass `areas` (each has a
276
- label, href, icon and optional child `items`), the router's `currentPath` and
277
- `LinkComponent`, plus `context`, `tools` and `account` slots. Include the real
278
- organization context, search, notifications and Switch app in these slots;
279
- render `UserMenu placement="top-start"` in `account`. The account stays at the
280
- bottom of the main rail. Existing `CboxAppShell` consumers retain their API.
281
-
282
- The rail reserves 64px, expands on hover/focus without moving content, and
283
- reserves 222px when pinned. Context navigation only appears for multi-page
284
- areas; its panel button and Command/Control-period collapse it to a 26px strip.
285
- Mobile uses a modal navigation sheet and retains the header app switcher.
286
- Query strings do not change active navigation, and the **most specific href wins**: an
287
- area at the organization root (`/acme`) does not claim `/acme/projects/...` when a
288
- Projects area exists, and inside a subnav only the deepest matching item is active. Active
289
- links carry `aria-current="page"`.
290
-
291
- Hosts own route construction, organization switching, search and preference persistence.
292
- For the two shell preferences the host persists, pass them in: `pinned` + `onPinnedChange`
293
- and `collapsed` + `onCollapsedChange` (controlled), or `defaultPinned` / `defaultCollapsed`
294
- to seed the shell's own state. `brandHref` points the brand row somewhere other than `/`
295
- (the current organization's overview), and `mainId` names the `<main>` landmark for the
296
- host's skip link (default `main-content`).
253
+ ## The shell
254
+
255
+ `CboxShell` is the one shell for every Cbox app (spec: `docs/specs/2026-11-10-shell-v1.md`).
256
+ The app passes the `shell` prop and the page; the shell does the rest.
257
+
258
+ ```
259
+ ┌──────┬────────────────────────────────────────────────────────────────────┬──────────┐
260
+ │ rail │ [C] Cbox ⇕ / [W] Webshop · ■ Production ⇕ [Search or ask… ⌘K] [Help²] [🔔] [⣿] │ Help │
261
+ │ ├────────────────────────────────────────────────────────────────────┤ (docked │
262
+ │ areas│ sandbox strip (only while the environment has test data) │ ≥1280px) │
263
+ │ … │ subnav │ page │ │
264
+ │ [SD] │ │ │ │
265
+ └──────┴────────┴───────────────────────────────────────────────────────────┴──────────┘
266
+ ```
267
+
268
+ ### The `shell` prop
269
+
270
+ The types are exported (`Shell`, `ContextLevel`, `ContextItem`, `NavArea`, `NavPage`,
271
+ `PaletteAction`, `ShellHelp`, `ShellPreferences`, …) and are the contract laravel-console-kit
272
+ produces. Abridged:
273
+
274
+ ```ts
275
+ type Shell = {
276
+ app: { key; name; homeUrl };
277
+ person: { id; name; initials; avatarUrl? };
278
+ page?: { title; route? }; // "On this page" and the chat's page intent
279
+ nav: { areas: NavArea[]; activeArea; activePage? };
280
+ context: { levels: ContextLevel[]; switchUrl }; // 1–3 levels, organization first
281
+ palette: { searchUrl?; gotoUrl?; actions: PaletteAction[] };
282
+ help: { enabled; topic?; sessionUrl?; scriptUrl?; portalUrl?; updatesUrl?; searchUrl?; articlesUrl? };
283
+ notifications: { enabled: false } | { enabled: true; unread; url };
284
+ apps: { catalogUrl; productsUrl? };
285
+ account: { profileUrl; switchAccountUrl; signOutUrl; signOutMethod?; languages?; formats?; signedInWith? };
286
+ banner?: { kind: 'sandbox' | 'impersonation' | 'notice'; text; action? };
287
+ preferences: { theme; locale; railPinned; helpOpen; subnavCollapsed?; regionalFormat?; timeZone? };
288
+ };
289
+ ```
290
+
291
+ `ContextLevel.settingsUrl` is a URL template on the wire: `{id}` is replaced with the row's id
292
+ (`/orgs/{id}/settings`). Nav and action icons are Lucide names (`"rocket"`, `"server"`); pass
293
+ `icons` to `CboxShell` for others.
294
+
295
+ ### What the app serves (all same-origin JSON)
296
+
297
+ | URL | Request | Answer |
298
+ |---|---|---|
299
+ | `level.listUrl` | `GET ?q=&parent=&cursor=&limit=` | `{ object: "list", data: ContextItem[], has_more, next_cursor }`. Loaded when a menu opens; every level below the first gets `parent`, the level above's id (projects of an organization, environments of a project). Answers are kept for the page and dropped when the context changes. |
300
+ | `level.createUrl` | `POST { name, parent? }` | `{ id, detailsUrl }` (or `details_url`). The person lands on `detailsUrl`. A 422 shows its first error under the field. |
301
+ | `account.switchAccountUrl` | A plain link (full page load) | Usually a redirect to Cbox ID's account chooser. |
302
+ | `context.switchUrl` | `POST { <level key>: id, …, return_to }` (a form post through `navigate`) | A redirect: the same page in the new context, or that area's list for an entity page (§5.6). |
303
+ | `palette.searchUrl` | `GET ?q=` | `{ data: [{ id, title, subtitle?, url }] }`, in the current context. |
304
+ | `palette.gotoUrl` | `GET ?q=` | `{ data: [{ title, subtitle?, levels?: { organization, project, … }, url? }] }` across organizations. |
305
+ | `apps.catalogUrl` | `GET` | The catalog below, plus `in_organization`, `admission_url`, `purpose` and `hue` per app. |
306
+ | `help.updatesUrl` | `GET`; `POST {updatesUrl}/{id}/read` | `{ data: [{ id, kind: "ticket" \| "chat", title, excerpt?, at, unread, url? }] }` |
307
+ | `help.searchUrl` | `GET ?q=&topic=` | `{ data: [{ id, title, excerpt?, body?, url?, source? }] }` |
308
+ | `help.articlesUrl` | `GET` (template, `{topic}`) | The same shape; the first three are shown. |
309
+
310
+ Every link the shell follows from these answers (`url`, `detailsUrl`) must be `http(s)://` or
311
+ a `/path`; anything else is dropped. None of these may call another service synchronously (local first): organizations come from
312
+ the app's mirror of Cbox ID, the catalog from a cached copy. Help and docs search are proxies of
313
+ Cbox Assistant served by laravel-assistant-client; when Assistant is down, Help says so and
314
+ everything else works.
315
+
316
+ Requests send `X-XSRF-TOKEN` (from the `XSRF-TOKEN` cookie) or `X-CSRF-TOKEN` (from
317
+ `<meta name="csrf-token">`). Without a `navigate` prop, POSTs are real form submits with
318
+ `_token`.
319
+
320
+ ### Preferences and first paint
321
+
322
+ The shell reports every preference change through `onPreferenceChange(key, value)`: `theme`,
323
+ `locale`, `regionalFormat`, `timeZone` (Cbox ID's shared preferences), and `railPinned`,
324
+ `helpOpen`, `subnavCollapsed` (the app's cookie, read on the server). Without the callback it
325
+ writes a `cbox_shell_<key>` cookie. The server sends them back in `shell.preferences`, so the
326
+ rail, Help and the subnav render in place on the server; `themeBootScript(theme)` (or the same
327
+ class and `data-theme` written by the server) sets the theme before paint.
328
+
329
+ ### Help
330
+
331
+ Help is part of the shell: support updates (the button's count), docs and knowledge base
332
+ search, articles for the page's `help.topic`, Ask Cbox, Talk to a person and the support portal.
333
+ It docks beside the page from 1280px (360px, 400px from 1440px) and is a full-screen dialog
334
+ below. Its state (open, article, search, scroll, updates) lives in a store outside React, so it
335
+ survives visits and even a remount; the chat element sits outside the home/article view, so
336
+ opening an article never ends the conversation, and it survives as long as the shell sits in
337
+ the persistent layout. Help the person left open shows docked on load; below 1280px it waits
338
+ until they open it, so a phone or a narrow window never lands under the overlay. Support
339
+ updates are per organization: they load again when the organization or `updatesUrl` changes.
340
+ Closing Help gives focus back to what opened it.
341
+
342
+ The chat is the Assistant SDK's `<cbox-support session-url>` element (laravel-assistant-client,
343
+ `/ai-assistant/support.js` by default, loaded the first time Help opens). The shell sets
344
+ `element.intent = { route, title, context, url }` (and an `intent` JSON attribute) on every
345
+ page, and calls `element.ask(question, intent)` / `element.handover()` when the SDK has them,
346
+ else `element.open()`.
347
+
348
+ A page's own `?` button uses `useShellHelp()?.toggleHelp()`, or `<PageHeader help />`. The page's
349
+ help topic is `shell.help.topic`, set on the server per route (console-kit's `helpTopic`), not
350
+ a prop of the header: Help needs it on the first paint and on pages without a header.
351
+
352
+ ### Keyboard
353
+
354
+ ⌘K / Ctrl+K opens the palette anywhere (↑↓ move, Enter opens, ⌘Enter opens in a new tab, Esc
355
+ closes). In the context menus ↑↓ Home End move, Enter picks, → makes a row's gear the active
356
+ option (Enter opens its settings; in the cascade a second → crosses to the environments), ←
357
+ goes back, Esc closes and Tab leaves; focus returns to the trigger. ↓ past the last row
358
+ reaches "New …" and ↑ above the first reaches "View all", so both work without a mouse; in the
359
+ app grid End and ↓ from the bottom row reach "All products". ⌘. collapses the subnav. Search fields focus themselves only with a fine pointer and
360
+ hover, never on touch. Sheets and the Help overlay trap focus; the docked column does not.
361
+
362
+ ### Phone (< 768px)
363
+
364
+ The header holds ☰ (areas, pages, Switch app, notifications, the account), one context button
365
+ (`Cbox / Webshop / Production`) that opens every level stacked, Search and Help (full screen).
366
+ Nothing is hidden without a replacement.
367
+
368
+ ### Tests and snapshots
369
+
370
+ `bun run test` runs the unit tests (vitest, Testing Library, happy-dom). `bun run test:visual`
371
+ renders the fixture page in `tests/visual` (the real shell, the fixture `shell` prop and its
372
+ answers) and compares every open state at 1440 and 390, light and dark, against
373
+ `tests/visual/__screenshots__`, and runs axe on every open state. It runs in the pinned
374
+ Playwright container (`scripts/visual.sh`, needs Docker), the same one CI's `visual` job uses,
375
+ so the Linux baselines match pixel for pixel. After an intended change:
376
+ `bun run test:visual:update`, then look at every changed image.
377
+
378
+ ### Tokens
297
379
 
298
380
  Core tokens now match the warm cream/ink design-system colors. Body text uses
299
381
  the system stack; hosts self-host Plus Jakarta Sans for headings and JetBrains