@cboxdk/cbox-ui 0.8.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
-
50
- ```tsx
51
- import { CboxIdProvider } from '@cboxdk/cbox-ui';
52
- import { useIdSession } from '@cboxdk/cbox-id-client';
53
52
 
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:
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.
65
62
 
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`)
63
+ ### 3. Identity context (inbox only)
73
64
 
74
- ### 3. Drop in the chrome
75
-
76
- ```tsx
77
- import { TopBar } from '@cboxdk/cbox-ui';
78
-
79
- <TopBar
80
- brand={<MyAppLogo />}
81
- centre={<MySearchBar />}
82
- showOrgSwitcher
83
- />
84
- ```
85
-
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 (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,60 +171,211 @@ callback; without it, existing browser formatting is preserved.
193
171
 
194
172
  ### App catalog (Switch app)
195
173
 
196
- `AppSwitcher` and `AppLauncher` render the static `apps` list from `CboxIdProvider`. Apps
197
- that read the person's catalog live from Cbox ID use `CboxAppCatalog` instead: the host
198
- serves a same-origin endpoint (a proxy of Cbox ID's catalog under the signed-in person's
199
- grant) and the component renders its answer, refreshes it when `refresh_after` expires
200
- while the panel is open, and offers the way back to Cbox ID when the grant is missing.
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
+
176
+ Every Cbox app, Blade or React, draws the same Switch-app control: the `<cbox-app-switcher>`
177
+ custom element from `@cboxdk/cbox-ui/app-switcher`. It has no React dependency and renders
178
+ in a shadow root with its own styles, so no app stylesheet (a reset, a missing preflight, a
179
+ global `button`, `img` or `svg` rule) changes how it looks. It takes only the host's Cbox
180
+ colour tokens (`--foreground`, `--popover`, `--border`, `--secondary`, `--muted`,
181
+ `--accent-soft`, `--ring`), each with a fallback.
182
+
183
+ **Blade (or any server-rendered page).** Import the module once in the app's Vite entry
184
+ (`resources/js/app.js`):
185
+
186
+ ```js
187
+ import '@cboxdk/cbox-ui/app-switcher';
188
+ ```
189
+
190
+ and put the tags in the layout:
191
+
192
+ ```blade
193
+ <cbox-app-switcher
194
+ endpoint="{{ $appCatalogUrl ?? '' }}"
195
+ current="billing"
196
+ labels="{{ json_encode(['title' => __('Switch app'), 'loading' => __('Loading apps…'), 'empty' => __('No apps available'), 'unavailable' => __('Apps could not be loaded.')], JSON_UNESCAPED_UNICODE) }}"
197
+ ></cbox-app-switcher>
198
+
199
+ {{-- The app's own mark (rail, brand), from the same catalog --}}
200
+ <cbox-service-mark service="billing" aria-hidden="true"></cbox-service-mark>
201
+ ```
202
+
203
+ **React.** `CboxAppCatalog` renders the same element:
201
204
 
202
205
  ```tsx
203
206
  <CboxAppCatalog endpoint={appCatalogUrl} currentAppKey="infra" />
204
207
  ```
205
208
 
206
- The endpoint contract (`parseAppCatalog` is exported, so a host can test its own answer):
209
+ Attributes of `<cbox-app-switcher>`:
210
+
211
+ | Attribute | |
212
+ |---|---|
213
+ | `endpoint` | The host's same-origin catalog URL. Empty disables the control (pages without an organization). Another origin is refused. |
214
+ | `current` | This app's key: its tile is marked current. |
215
+ | `labels` | JSON with any of `title`, `loading`, `empty`, `unavailable`. Missing keys stay English. |
216
+ | `theme` | `auto` (default): `.dark`/`[data-theme="dark"]` or `.light`/`[data-theme="light"]` on `<html>`, else `prefers-color-scheme`. `class`: only the root marker counts, no marker is light (a host whose tokens only turn dark under `.dark`). `light`/`dark` pin it. |
217
+
218
+ `<cbox-service-mark service="key">` paints that app's icon from the catalog this browser last
219
+ kept, and repaints when a newer one arrives. It is 24px; set
220
+ `cbox-service-mark { --cbox-service-mark-size: 22px; }` to change it. It takes `theme` too.
221
+
222
+ The endpoint contract (`parseAppCatalog` and `signInTarget` are exported, so a host can test
223
+ its own answer):
207
224
 
208
225
  - `200 { apps: [{ key, name, launch_url, icon_url?, icon_dark_url?, description? }], refresh_after?: seconds }`
209
226
  — `refresh_after` is clamped to 1–60 s; apps without an `http(s)` launch URL are dropped.
210
- - `401`/`403` with `{ authorize_url }` or `{ state: 'authorization_required' }` — the panel
211
- says the apps could not be loaded and links "Reconnect to Cbox ID" to `authorize_url` with
212
- `return_to` set to the current path (same-origin URLs only).
213
- - Anything else — "Apps could not be loaded." with a retry.
214
-
215
- `endpoint={null}` renders the control disabled, so the chrome keeps its shape on pages
216
- without an organization.
217
-
218
- App icons come from Cbox ID's catalog: `icon_url` for light surfaces and `icon_dark_url` for
219
- dark ones. The tiles render both and the token stylesheet shows the one for the current theme
220
- (`.dark` or `[data-theme="dark"]` on the root); an app without an icon shows its initials. Apps
221
- keep no local copy of the marks. Each answer is kept in this browser (`cbox-app-catalog`), so
222
- `<CboxServiceMark appKey="infra" />` and the collapsed `CboxBrand` in the rail paint the app's
223
- own icon at once on the next page. A host that loads the catalog itself calls
224
- `rememberAppCatalog(apps)`; `CboxAppIcon` renders any single app's icon.
225
-
226
- ### Workspace navigation (current design contract)
227
-
228
- `CboxWorkspaceShell` is the two-tier product shell. Pass `areas` (each has a
229
- label, href, icon and optional child `items`), the router's `currentPath` and
230
- `LinkComponent`, plus `context`, `tools` and `account` slots. Include the real
231
- organization context, search, notifications and Switch app in these slots;
232
- render `UserMenu placement="top-start"` in `account`. The account stays at the
233
- bottom of the main rail. Existing `CboxAppShell` consumers retain their API.
234
-
235
- The rail reserves 64px, expands on hover/focus without moving content, and
236
- reserves 222px when pinned. Context navigation only appears for multi-page
237
- areas; its panel button and Command/Control-period collapse it to a 26px strip.
238
- Mobile uses a modal navigation sheet and retains the header app switcher.
239
- Query strings do not change active navigation, and the **most specific href wins**: an
240
- area at the organization root (`/acme`) does not claim `/acme/projects/...` when a
241
- Projects area exists, and inside a subnav only the deepest matching item is active. Active
242
- links carry `aria-current="page"`.
243
-
244
- Hosts own route construction, organization switching, search and preference persistence.
245
- For the two shell preferences the host persists, pass them in: `pinned` + `onPinnedChange`
246
- and `collapsed` + `onCollapsedChange` (controlled), or `defaultPinned` / `defaultCollapsed`
247
- to seed the shell's own state. `brandHref` points the brand row somewhere other than `/`
248
- (the current organization's overview), and `mainId` names the `<main>` landmark for the
249
- host's skip link (default `main-content`).
227
+ - `401`/`403 { signin_url }` — the person has to sign in again. When they open the control
228
+ (or it is open when the answer arrives), the element sends the top window to `signin_url`:
229
+ no message, no button. A page that merely loads in the background is never moved. If the
230
+ same sign-in was tried in the last 30 seconds and the catalog still refuses, it shows "Apps
231
+ could not be loaded." instead, so it never loops. The legacy `{ authorize_url }` is followed
232
+ the same way, with `return_to` set to the current path (same-origin URLs only).
233
+ `signin_url` must be on the app's own origin; anything else is ignored.
234
+ - Anything else, or no network — "Apps could not be loaded.", quietly, and it tries again by
235
+ itself (2 s, 4 s, 8 s … up to 60 s). A grid it already shows stays.
236
+
237
+ Before navigating it fires `cbox-app-switcher:signin` (bubbling, composed, cancelable, with
238
+ `detail.url`); `preventDefault()` keeps the page where it is.
239
+
240
+ App icons come from Cbox ID's catalog only: `icon_url` for light surfaces and
241
+ `icon_dark_url` for dark ones; the element swaps them with the theme. Only `https:` icons
242
+ render; an app without one, or whose icon fails to load, shows its initials. Apps keep no
243
+ local copy of the marks. Each answer is kept in this browser (`localStorage`,
244
+ `cbox-app-catalog`), so the grid and the marks paint at once on the next page and the live
245
+ answer replaces them. The icons are served by Cbox ID, so the host's CSP needs
246
+ `img-src https://cbox.cboxid.com` (the element's styles are constructed stylesheets, which
247
+ `style-src` does not govern).
248
+
249
+ In React, `<CboxServiceMark appKey="infra" />` and the collapsed `CboxBrand` read the same
250
+ kept catalog; `CboxAppIcon` renders any single app's icon, and a host that loads the catalog
251
+ itself calls `rememberAppCatalog(apps)`.
252
+
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
250
379
 
251
380
  Core tokens now match the warm cream/ink design-system colors. Body text uses
252
381
  the system stack; hosts self-host Plus Jakarta Sans for headings and JetBrains
@@ -0,0 +1,19 @@
1
+ export { b as CboxAppCatalogState, c as CboxCatalogApp, a as CboxKnownApp, C as CboxThemeMode, d as authorizeHref, i as isDark, p as parseAppCatalog, r as readKnownAppCatalog, e as rememberAppCatalog, s as secureIconUrl, f as signInTarget, g as subscribeKnownAppCatalog } from './catalog-BroZWvws.js';
2
+
3
+ /** The words the control shows. Pass a translation as the `labels` attribute (JSON). */
4
+ type CboxAppSwitcherLabels = {
5
+ title: string;
6
+ loading: string;
7
+ empty: string;
8
+ unavailable: string;
9
+ };
10
+ declare const DEFAULT_LABELS: CboxAppSwitcherLabels;
11
+ /**
12
+ * Fired on `<cbox-app-switcher>` (bubbling, composed, cancelable) just before it sends the
13
+ * person to sign in again; `detail.url` is where. `preventDefault()` keeps the page where it is.
14
+ */
15
+ declare const SIGNIN_EVENT = "cbox-app-switcher:signin";
16
+ /** Define `<cbox-app-switcher>` and `<cbox-service-mark>` once. A no-op outside a browser. */
17
+ declare function defineCboxAppSwitcher(): void;
18
+
19
+ export { type CboxAppSwitcherLabels, DEFAULT_LABELS, SIGNIN_EVENT, defineCboxAppSwitcher };
@@ -0,0 +1,7 @@
1
+ import { defineCboxAppSwitcher } from './chunk-AJCYF7SS.js';
2
+ export { DEFAULT_LABELS, SIGNIN_EVENT, authorizeHref, defineCboxAppSwitcher, isDark, parseAppCatalog, readKnownAppCatalog, rememberAppCatalog, secureIconUrl, signInTarget, subscribeKnownAppCatalog } from './chunk-AJCYF7SS.js';
3
+
4
+ // src/app-switcher/index.ts
5
+ defineCboxAppSwitcher();
6
+ //# sourceMappingURL=app-switcher.js.map
7
+ //# sourceMappingURL=app-switcher.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/app-switcher/index.ts"],"names":[],"mappings":";;;;AAcA,qBAAA,EAAsB","file":"app-switcher.js","sourcesContent":["/**\n * `@cboxdk/cbox-ui/app-switcher` — the one Switch-app control for every Cbox app, framework\n * free. Importing this module defines `<cbox-app-switcher>` and `<cbox-service-mark>`:\n *\n * import '@cboxdk/cbox-ui/app-switcher';\n *\n * <cbox-app-switcher endpoint=\"/acme/apps/catalog\" current=\"billing\"\n * labels='{\"title\":\"Skift app\"}'></cbox-app-switcher>\n * <cbox-service-mark service=\"billing\" aria-hidden=\"true\"></cbox-service-mark>\n *\n * Both render in a shadow root with their own styles, so no app stylesheet changes them.\n */\nimport { defineCboxAppSwitcher } from './elements';\n\ndefineCboxAppSwitcher();\n\nexport { defineCboxAppSwitcher, DEFAULT_LABELS, SIGNIN_EVENT, type CboxAppSwitcherLabels } from './elements';\nexport {\n authorizeHref,\n parseAppCatalog,\n readKnownAppCatalog,\n rememberAppCatalog,\n secureIconUrl,\n signInTarget,\n subscribeKnownAppCatalog,\n type CboxAppCatalogState,\n type CboxCatalogApp,\n type CboxKnownApp,\n} from './catalog';\nexport { isDark, type CboxThemeMode } from './theme';\n"]}
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Which theme the host page shows, read the way every Cbox app marks it:
3
+ *
4
+ * - `.dark` or `[data-theme="dark"]` on the root element: dark;
5
+ * - `.light` or `[data-theme="light"]` on the root element: light;
6
+ * - neither: the system's `prefers-color-scheme`, unless the element says `theme="class"`
7
+ * (a host whose tokens only turn dark under `.dark`, so no marker means light).
8
+ *
9
+ * `theme="light"` or `theme="dark"` on the element pins it.
10
+ */
11
+ type CboxThemeMode = 'auto' | 'class' | 'light' | 'dark';
12
+ declare function isDark(mode?: CboxThemeMode): boolean;
13
+
14
+ /**
15
+ * The app catalog contract every Cbox app serves, and the copy of it this browser keeps.
16
+ * Framework-free: the `<cbox-app-switcher>` element and the React components both read it.
17
+ *
18
+ * Every app's icon comes from Cbox ID's catalog: one light and one dark upload per app
19
+ * (`icon_url`, `icon_dark_url`). No app keeps a local copy of the marks.
20
+ */
21
+ /** One app as the host's catalog endpoint lists it. */
22
+ type CboxCatalogApp = {
23
+ key: string;
24
+ name: string;
25
+ launch_url: string;
26
+ /** Cbox ID's uploaded icon for light surfaces. */
27
+ icon_url?: string | null;
28
+ /** Cbox ID's uploaded icon for dark surfaces. */
29
+ icon_dark_url?: string | null;
30
+ description?: string | null;
31
+ };
32
+ type CboxAppCatalogState = {
33
+ status: 'idle' | 'loading';
34
+ } | {
35
+ status: 'ready';
36
+ apps: CboxCatalogApp[];
37
+ refreshAfter: number;
38
+ }
39
+ /**
40
+ * The person has to sign in again. `signinUrl` is the new contract (the host's `401
41
+ * { signin_url }`), `authorizeUrl` the legacy one (`{ authorize_url }`).
42
+ */
43
+ | {
44
+ status: 'signin_required';
45
+ signinUrl: string | null;
46
+ authorizeUrl: string | null;
47
+ } | {
48
+ status: 'unavailable';
49
+ };
50
+ /** One app as this browser keeps it between pages. */
51
+ type CboxKnownApp = {
52
+ key: string;
53
+ name: string;
54
+ icon_url: string | null;
55
+ icon_dark_url: string | null;
56
+ launch_url?: string;
57
+ };
58
+ /**
59
+ * Interpret the catalog endpoint's answer:
60
+ *
61
+ * - `200 { apps: [{ key, name, launch_url, icon_url?, icon_dark_url?, description? }], refresh_after?: seconds }`
62
+ * - `401`/`403 { signin_url }`: the person signs in again there. The legacy `{ authorize_url }`
63
+ * and `{ state: 'authorization_required' }` mean the same.
64
+ * - anything else: unavailable right now.
65
+ *
66
+ * Only `http(s)` launch URLs are kept: the tiles are links the person will click.
67
+ */
68
+ declare function parseAppCatalog(ok: boolean, body: unknown): CboxAppCatalogState;
69
+ type Here = {
70
+ origin: string;
71
+ pathname: string;
72
+ search: string;
73
+ };
74
+ /** The legacy authorize link with `return_to` set to where the person is, on this origin only. */
75
+ declare function authorizeHref(authorizeUrl: string | null, location: Here): string | null;
76
+ /**
77
+ * Where to send the person when the catalog says they must sign in again: the host's
78
+ * `signin_url` when it is on this origin (the app's own sign-in; never another site), else the legacy authorize
79
+ * link with `return_to`. `null` when neither is usable.
80
+ */
81
+ declare function signInTarget(state: {
82
+ signinUrl: string | null;
83
+ authorizeUrl: string | null;
84
+ }, location: Here): string | null;
85
+ /** Only `https:` icon URLs are rendered: the icons are hosted by Cbox ID. */
86
+ declare function secureIconUrl(url: unknown): string | null;
87
+ /** The last catalog this browser kept (`localStorage` key `cbox-app-catalog`); empty without one. */
88
+ declare function readKnownAppCatalog(): CboxKnownApp[];
89
+ /** Keep the catalog the person just loaded, and tell every mark on this page to repaint. */
90
+ declare function rememberAppCatalog(apps: ReadonlyArray<{
91
+ key: string;
92
+ name: string;
93
+ launch_url?: string | null;
94
+ icon_url?: string | null;
95
+ icon_dark_url?: string | null;
96
+ }>): void;
97
+ /** Call `change` whenever the kept catalog changes, on this page or in another tab. */
98
+ declare function subscribeKnownAppCatalog(change: () => void): () => void;
99
+
100
+ export { type CboxThemeMode as C, type CboxKnownApp as a, type CboxAppCatalogState as b, type CboxCatalogApp as c, authorizeHref as d, rememberAppCatalog as e, signInTarget as f, subscribeKnownAppCatalog as g, isDark as i, parseAppCatalog as p, readKnownAppCatalog as r, secureIconUrl as s };