@cosmoops/connect-sdk 0.9.0 → 0.15.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.
Files changed (139) hide show
  1. package/CHANGELOG.md +497 -0
  2. package/README.md +111 -41
  3. package/dist/cjs/cli/doctor.js +3 -3
  4. package/dist/cjs/cli/init.js +3 -3
  5. package/dist/cjs/client/components/access-gate.js +20 -1
  6. package/dist/cjs/client/components/app-shell.js +75 -41
  7. package/dist/cjs/client/components/apps-grid.js +3 -2
  8. package/dist/cjs/client/components/ask-ai.js +20 -1
  9. package/dist/cjs/client/components/confirm-dialog.js +40 -0
  10. package/dist/cjs/client/components/cursor-pagination.js +22 -0
  11. package/dist/cjs/client/components/filter-tabs.js +24 -0
  12. package/dist/cjs/client/components/grievance-notice.js +30 -0
  13. package/dist/cjs/client/components/link-button.js +17 -10
  14. package/dist/cjs/client/components/logo.js +4 -3
  15. package/dist/cjs/client/components/nav.js +41 -0
  16. package/dist/cjs/client/components/plan-pricing.js +27 -0
  17. package/dist/cjs/client/components/plans.js +1 -1
  18. package/dist/cjs/client/components/platform-gate.js +48 -0
  19. package/dist/cjs/client/components/shell-context.js +149 -0
  20. package/dist/cjs/client/components/side-nav.js +49 -23
  21. package/dist/cjs/client/components/sidebar.js +127 -0
  22. package/dist/cjs/client/components/subscribe-dialog.js +2 -2
  23. package/dist/cjs/client/components/subscription.js +1 -1
  24. package/dist/cjs/client/components/user-menu.js +96 -37
  25. package/dist/cjs/client/components/workspace-switcher.js +100 -0
  26. package/dist/cjs/client/index.js +69 -2
  27. package/dist/cjs/client/provider.js +57 -2
  28. package/dist/cjs/client/use-checkout.js +9 -5
  29. package/dist/cjs/errors.js +1 -1
  30. package/dist/cjs/index.js +11 -1
  31. package/dist/cjs/next/index.js +2 -2
  32. package/dist/cjs/server/client.js +25 -2
  33. package/dist/cjs/server/handler.js +196 -18
  34. package/dist/cjs/server/http.js +6 -2
  35. package/dist/cjs/server/resources/acting-as.js +16 -0
  36. package/dist/cjs/server/resources/ai.js +15 -2
  37. package/dist/cjs/server/resources/data.js +374 -0
  38. package/dist/cjs/server/resources/identity.js +7 -3
  39. package/dist/cjs/server/resources/kv.js +84 -0
  40. package/dist/cjs/server/resources/platform.js +43 -0
  41. package/dist/cjs/server/resources/sessions.js +1 -1
  42. package/dist/cjs/server/resources/workspaces.js +74 -0
  43. package/dist/cjs/store.js +152 -0
  44. package/dist/cjs/ui/empty-state.js +28 -0
  45. package/dist/cjs/ui/index.js +44 -0
  46. package/dist/cjs/ui/page-header.js +27 -0
  47. package/dist/cjs/ui/page-shell.js +44 -0
  48. package/dist/cjs/ui/section.js +20 -0
  49. package/dist/cjs/ui/setting-row.js +14 -0
  50. package/dist/cjs/ui/status-chip.js +17 -0
  51. package/dist/esm/cli/doctor.js +3 -3
  52. package/dist/esm/cli/init.js +3 -3
  53. package/dist/esm/client/components/access-gate.js +20 -1
  54. package/dist/esm/client/components/app-shell.js +81 -47
  55. package/dist/esm/client/components/apps-grid.js +3 -2
  56. package/dist/esm/client/components/ask-ai.js +20 -1
  57. package/dist/esm/client/components/confirm-dialog.js +37 -0
  58. package/dist/esm/client/components/cursor-pagination.js +19 -0
  59. package/dist/esm/client/components/filter-tabs.js +21 -0
  60. package/dist/esm/client/components/grievance-notice.js +27 -0
  61. package/dist/esm/client/components/link-button.js +17 -10
  62. package/dist/esm/client/components/logo.js +4 -3
  63. package/dist/esm/client/components/nav.js +40 -0
  64. package/dist/esm/client/components/plan-pricing.js +25 -0
  65. package/dist/esm/client/components/plans.js +2 -2
  66. package/dist/esm/client/components/platform-gate.js +45 -0
  67. package/dist/esm/client/components/shell-context.js +141 -0
  68. package/dist/esm/client/components/side-nav.js +48 -21
  69. package/dist/esm/client/components/sidebar.js +120 -0
  70. package/dist/esm/client/components/subscribe-dialog.js +3 -3
  71. package/dist/esm/client/components/subscription.js +2 -2
  72. package/dist/esm/client/components/user-menu.js +95 -38
  73. package/dist/esm/client/components/workspace-switcher.js +97 -0
  74. package/dist/esm/client/index.js +46 -4
  75. package/dist/esm/client/provider.js +53 -2
  76. package/dist/esm/client/use-checkout.js +9 -5
  77. package/dist/esm/errors.js +1 -1
  78. package/dist/esm/index.js +1 -0
  79. package/dist/esm/next/index.js +2 -2
  80. package/dist/esm/server/client.js +25 -2
  81. package/dist/esm/server/handler.js +196 -18
  82. package/dist/esm/server/http.js +6 -2
  83. package/dist/esm/server/resources/acting-as.js +13 -0
  84. package/dist/esm/server/resources/ai.js +15 -2
  85. package/dist/esm/server/resources/data.js +369 -0
  86. package/dist/esm/server/resources/identity.js +7 -3
  87. package/dist/esm/server/resources/kv.js +80 -0
  88. package/dist/esm/server/resources/platform.js +39 -0
  89. package/dist/esm/server/resources/sessions.js +1 -1
  90. package/dist/esm/server/resources/workspaces.js +70 -0
  91. package/dist/esm/store.js +141 -0
  92. package/dist/esm/ui/empty-state.js +25 -0
  93. package/dist/esm/ui/index.js +34 -0
  94. package/dist/esm/ui/page-header.js +24 -0
  95. package/dist/esm/ui/page-shell.js +41 -0
  96. package/dist/esm/ui/section.js +17 -0
  97. package/dist/esm/ui/setting-row.js +11 -0
  98. package/dist/esm/ui/status-chip.js +14 -0
  99. package/dist/types/client/components/app-shell.d.ts +65 -27
  100. package/dist/types/client/components/apps-grid.d.ts +11 -2
  101. package/dist/types/client/components/confirm-dialog.d.ts +34 -0
  102. package/dist/types/client/components/cursor-pagination.d.ts +27 -0
  103. package/dist/types/client/components/filter-tabs.d.ts +26 -0
  104. package/dist/types/client/components/grievance-notice.d.ts +31 -0
  105. package/dist/types/client/components/link-button.d.ts +32 -9
  106. package/dist/types/client/components/logo.d.ts +18 -2
  107. package/dist/types/client/components/nav.d.ts +24 -0
  108. package/dist/types/client/components/plan-pricing.d.ts +10 -0
  109. package/dist/types/client/components/platform-gate.d.ts +53 -0
  110. package/dist/types/client/components/shell-context.d.ts +116 -0
  111. package/dist/types/client/components/side-nav.d.ts +27 -13
  112. package/dist/types/client/components/sidebar.d.ts +95 -0
  113. package/dist/types/client/components/user-menu.d.ts +68 -22
  114. package/dist/types/client/components/workspace-switcher.d.ts +49 -0
  115. package/dist/types/client/index.d.ts +27 -5
  116. package/dist/types/client/provider.d.ts +109 -6
  117. package/dist/types/client/use-checkout.d.ts +8 -4
  118. package/dist/types/errors.d.ts +1 -1
  119. package/dist/types/index.d.ts +2 -0
  120. package/dist/types/next/index.d.ts +1 -1
  121. package/dist/types/server/client.d.ts +25 -2
  122. package/dist/types/server/http.d.ts +8 -2
  123. package/dist/types/server/index.d.ts +4 -0
  124. package/dist/types/server/resources/acting-as.d.ts +11 -0
  125. package/dist/types/server/resources/ai.d.ts +9 -1
  126. package/dist/types/server/resources/data.d.ts +258 -0
  127. package/dist/types/server/resources/kv.d.ts +63 -0
  128. package/dist/types/server/resources/platform.d.ts +32 -0
  129. package/dist/types/server/resources/workspaces.d.ts +55 -0
  130. package/dist/types/store.d.ts +96 -0
  131. package/dist/types/types.d.ts +319 -15
  132. package/dist/types/ui/empty-state.d.ts +34 -0
  133. package/dist/types/ui/index.d.ts +40 -0
  134. package/dist/types/ui/page-header.d.ts +34 -0
  135. package/dist/types/ui/page-shell.d.ts +39 -0
  136. package/dist/types/ui/section.d.ts +26 -0
  137. package/dist/types/ui/setting-row.d.ts +18 -0
  138. package/dist/types/ui/status-chip.d.ts +36 -0
  139. package/package.json +6 -2
package/README.md CHANGED
@@ -35,7 +35,7 @@ The fast path — most integrations are this and nothing else. Not on Next.js?
35
35
  Skip to [Other frameworks](#other-frameworks); the shape is the same, you
36
36
  just wire the session cookie yourself.
37
37
 
38
- **1. Install and get credentials.** From your application's Integration tab
38
+ **1. Install and get credentials.** From your application's Connect client section
39
39
  in Connect, copy the API key and OAuth client id.
40
40
 
41
41
  ```bash
@@ -87,7 +87,7 @@ import "streamdown/styles.css";
87
87
  Skip the last two if you pass `askAi={false}` to `AppShell`. The colour tokens
88
88
  Streamdown expects are already mapped onto HeroUI's in `theme.css`.
89
89
 
90
- **5. Register the redirect URI.** In the application's Integration tab, add
90
+ **5. Register the redirect URI.** In the application's Connect client section, add
91
91
  `{your origin}/api/connect/callback`. This is the one step the SDK cannot do
92
92
  for you.
93
93
 
@@ -123,7 +123,7 @@ layout and Connect's dashboard rather than files it can safely write. See
123
123
 
124
124
  Two things most integrations add next:
125
125
 
126
- - **A shell** — `AppShell` from `/client` is the sidebar, top bar and access
126
+ - **A shell** — `AppShell` from `/client` is the rail and the access
127
127
  gate every application platform needs. See [App shell](#app-shell).
128
128
  - **Route protection before render** — see [Route protection and
129
129
  `proxy.ts`](#route-protection-and-proxyts) below; `requireConnectSession()`
@@ -136,7 +136,7 @@ for you under the hood.
136
136
 
137
137
  ## Server setup
138
138
 
139
- One credential does three jobs — the API key plaintext from your application's Integration tab is also your OAuth2 `client_secret` and the key your `id_token` is signed with. Revoking it revokes all three.
139
+ One credential does three jobs — the API key plaintext from your application's Connect client section is also your OAuth2 `client_secret` and the key your `id_token` is signed with. Revoking it revokes all three.
140
140
 
141
141
  ```ts
142
142
  import { ConnectClient } from "@cosmoops/connect-sdk/server";
@@ -518,17 +518,17 @@ that URL yourself instead.
518
518
  import {
519
519
  AppShell,
520
520
  ConnectLoginButton, ConnectRegisterButton,
521
- ConnectUserMenu, ConnectAppsGrid, ConnectPlans,
521
+ ConnectUserButton, ConnectAppsGrid, ConnectPlans,
522
522
  } from "@cosmoops/connect-sdk/client";
523
523
 
524
- // The whole frame — sidebar, top bar, access gate, Ask AI. See "App shell" below.
525
- <AppShell name="Dispatch" nav={nav}>{children}</AppShell>
524
+ // The whole frame — the rail, the access gate, Ask AI. See "App shell" below.
525
+ <AppShell name="Dispatch" nav={nav} secondaryNav={settingsNav}>{children}</AppShell>
526
526
 
527
527
  // Or the pieces, if you already have a layout. The pair swaps automatically
528
528
  // on sign-in state.
529
- <ConnectAppsGrid />
529
+ <ConnectAppsGrid variant="icon" />
530
530
  <ConnectLoginButton />
531
- <ConnectUserMenu />
531
+ <ConnectUserButton variant="inline" />
532
532
 
533
533
  // Pricing page
534
534
  <ConnectPlans />
@@ -536,10 +536,13 @@ import {
536
536
 
537
537
  | Component | What it does |
538
538
  | --- | --- |
539
- | `AppShell` | **The whole application frame** — collapsible sidebar, top bar with the launcher and account menu, and your pages in the middle. Mounts `ConnectAccessGate` for you. |
540
- | `ConnectSideNav` | The sidebar's nav column on its own — sections, one level of children, active-route highlight — for an app that already has its own chrome. |
539
+ | `AppShell` | **The whole application frame** — one collapsible rail carrying the brand, workspace switcher, both navs, the launcher and the account row, with your pages beside it. No top bar. Mounts `ConnectAccessGate` for you. The one `/client` export without the `Connect` prefix; alias it on import if your own layout shares the name. |
540
+ | `ConnectSidebar` · `ConnectSidebarHeader` · `ConnectSidebarBody` · `ConnectSidebarFooter` · `ConnectSidebarRow` | The rail in pieces, for chrome the props cannot express. `AppShell` composes exactly these; they coordinate through `ConnectShellProvider`. |
541
+ | `ConnectSidebarNav` | The rail's nav column on its own — sections, one level of children, active-route highlight — for an app that already has its own chrome. (Was `ConnectSideNav`.) |
541
542
  | `ConnectLoginButton` · `ConnectRegisterButton` | "Continue with Connect", in the shape people know from Google. Render nothing when signed in. |
542
- | `ConnectUserMenu` | Avatar, name, workspace. Profile, Manage subscription and Open Connect each open Connect in a new tab; Sign out acts in place. Renders nothing when signed out. |
543
+ | `ConnectUserButton` | The account row at the foot of the rail — circular avatar, name over email — opening a modal with identity, sessions and the Connect destinations. Profile, Manage subscription and Open Connect each open Connect in a new tab; Sign out acts in place. Renders nothing when signed out. (Was `ConnectUserMenu`.) |
544
+ | `ConnectProfileModal` | The same modal with no trigger, driven by `isOpen`/`onOpenChange` — for an app keeping its own account control. |
545
+ | `ConnectWorkspaceSwitcher` | Sits in the rail's header. Hides itself for an account with one workspace; shows unusable ones disabled, with the reason. |
543
546
  | `ConnectAppsGrid` | Google-style launcher — grid icon opening a modal of every app this workspace can open. |
544
547
  | `ConnectPlans` | Monthly/annual toggle, features, add-ons, live discount, coupon field, and the subscribe / upgrade / downgrade / cancel flow. |
545
548
  | `ConnectPlansPage` | A whole `/plans` page — readiness banner, current subscription, and the grid. What `connect-sdk init` scaffolds. |
@@ -706,21 +709,20 @@ export function Shell({ children }: { children: React.ReactNode }) {
706
709
  nav={[
707
710
  { label: "Overview", href: "/", icon: <LayoutDashboard className="size-5" /> },
708
711
  { label: "Shipments", href: "/shipments", icon: <Truck className="size-5" /> },
712
+ ]}
713
+ // Pinned to the foot of the rail: the routes you go to in order to
714
+ // change how the job works, not to do it.
715
+ secondaryNav={[
709
716
  {
710
- title: "Workspace",
717
+ label: "Settings",
718
+ href: "/settings",
719
+ icon: <Settings className="size-5" />,
711
720
  items: [
712
- {
713
- label: "Settings",
714
- href: "/settings",
715
- icon: <Settings className="size-5" />,
716
- items: [
717
- { label: "Members", href: "/settings/members" },
718
- { label: "Billing", href: "/settings/billing" },
719
- ],
720
- },
721
- { label: "Plans", href: "/plans", icon: <Receipt className="size-5" />, gateExempt: true },
721
+ { label: "Members", href: "/settings/members" },
722
+ { label: "Billing", href: "/settings/billing" },
722
723
  ],
723
724
  },
725
+ { label: "Plans", href: "/plans", icon: <Receipt className="size-5" />, gateExempt: true },
724
726
  ]}
725
727
  >
726
728
  {children}
@@ -729,10 +731,16 @@ export function Shell({ children }: { children: React.ReactNode }) {
729
731
  }
730
732
  ```
731
733
 
732
- That is the entire layout of a application platform. Everything that should look
733
- identical across the family — the lockup, the rail geometry, the active-route
734
- treatment, the app launcher, the account menu, the mobile drawer — comes from
735
- here; only `name`, `icon` and `nav` differ per platform.
734
+ That is the entire layout of an application platform. Everything that should
735
+ look identical across the family — the lockup, the rail geometry, the
736
+ active-route treatment, the app launcher, the account row, the mobile drawer —
737
+ comes from here; only `name`, `icon` and the two navs differ per platform.
738
+
739
+ **There is no top bar.** The launcher and the account row live at the foot of
740
+ the rail, one press from wherever the pointer already is, and a page's own
741
+ heading belongs to the page — put it in `ConnectPageShell` from
742
+ `@cosmoops/connect-sdk/ui`. Below `md` a slim bar carries the drawer trigger
743
+ and nothing else.
736
744
 
737
745
  **Routing is a prop.** The SDK ships into any React app and cannot import
738
746
  `next/link`, so `renderLink` is how your router gets involved; the default is a
@@ -747,7 +755,7 @@ around `children` — an app that ships a shell and forgets the gate is an app
747
755
  that lets a suspended workspace keep working. The chrome is never gated: the
748
756
  sidebar, the lockup, the account menu and sign-out stay reachable in every
749
757
  blocked state. Routes that resolve a block rather than needing one — your
750
- pricing page — are flagged where they are declared:
758
+ pricing page — are flagged where they are declared, in either nav:
751
759
 
752
760
  ```tsx
753
761
  { label: "Plans", href: "/plans", gateExempt: true }
@@ -771,26 +779,38 @@ Connect, and `ai:chat` on the API key behind your bridge.
771
779
 
772
780
  Other things worth knowing:
773
781
 
774
- - **The rail collapses to icons** and remembers the choice per browser
775
- (`localStorage`). Drive it yourself with `collapsed` + `onCollapsedChange`,
776
- or pin it open with `collapsible={false}`.
777
- - **Below `md` there is no rail**, only a drawer behind the top bar's menu
782
+ - **The rail starts collapsed** and remembers the choice per browser
783
+ (`localStorage`). The icon rail is the resting state; expanding is the
784
+ deliberate act. Expanded, the collapse control sits at the header's trailing
785
+ edge; collapsed, hovering the lockup swaps it for the expand glyph — so the
786
+ rail spends no row on a permanent control, at the cost of the lockup no
787
+ longer being a link home while collapsed. Pass `defaultCollapsed={false}` to
788
+ open by default, drive it with `collapsed` + `onCollapsedChange`, or pin it
789
+ open with `collapsible={false}`.
790
+ - **The collapse animates width and nothing else.** Every row is laid out so
791
+ its glyph sits on the same horizontal centre in both states, and labels are
792
+ clipped rather than re-measured — so no row reflows and nothing drifts
793
+ sideways mid-transition.
794
+ - **Below `md` there is no rail**, only a drawer behind the mobile bar's menu
778
795
  button — a 64px icon strip on a phone is a worse drawer, not a smaller one.
779
796
  - **Children unfold only on the branch you are on.** A sidebar that opens
780
797
  every branch at once is a sitemap.
781
798
  - **No `isSignedIn` branch, in here or in yours.** The auth buttons render
782
- nothing once a session exists, the account menu renders nothing without one,
799
+ nothing once a session exists, the account row renders nothing without one,
783
800
  and the launcher renders nothing when signed out.
784
- - `title`, `headerActions`, `sidebarFooter` and `footer` are slots;
785
- `contentClassName` replaces the content pane's default max-width and
786
- padding.
787
- - `theme` / `onThemeChange` pass straight through to `ConnectUserMenu` — the
801
+ - `secondaryNav`, `sidebarFooter` and `footer` are slots; `contentClassName`
802
+ replaces the content pane's default max-width and padding. (`title` and
803
+ `headerActions` are gone with the top bar — see the 0.12.0 changelog entry.)
804
+ - `theme` / `onThemeChange` pass straight through to `ConnectUserButton` — the
788
805
  SDK has no theme provider of its own, deliberately.
789
806
 
790
- `ConnectSideNav` is the nav column alone, for an app with chrome it already
791
- likes. `resolveActivePath` and `isNavItemActive` are the same pure functions
792
- the rail highlights with, exported so a breadcrumb or a document title derived
793
- from your nav cannot disagree with it.
807
+ `ConnectSidebarNav` is the nav column alone, for an app with chrome it already
808
+ likes, and `ConnectSidebar` / `ConnectSidebarHeader` / `ConnectSidebarBody` /
809
+ `ConnectSidebarFooter` / `ConnectSidebarRow` are the rest of the rail — the
810
+ exact pieces `AppShell` composes, so chrome the props cannot express is a
811
+ rearrangement rather than a fork. `resolveActivePath` and `isNavItemActive` are
812
+ the same pure functions the rail highlights with, exported so a breadcrumb or a
813
+ document title derived from your nav cannot disagree with it.
794
814
 
795
815
  ### Logo
796
816
 
@@ -825,6 +845,56 @@ So the hand-off is a URL, and Connect renders it as a modal over the relevant pa
825
845
 
826
846
  Full history: [CHANGELOG.md](./CHANGELOG.md). Latest first:
827
847
 
848
+ ### 0.12.0 — breaking
849
+
850
+ The application shell is one column. **The top bar is gone**; the rail holds
851
+ everything it used to, and starts collapsed.
852
+
853
+ - **`title` and `headerActions` are removed** with the bar that held them. A
854
+ page's heading goes in `ConnectPageShell` (from `/ui`), next to that page's
855
+ own breadcrumbs and actions; an application-level control goes in
856
+ `sidebarFooter` as a `ConnectSidebarRow`.
857
+ - **`defaultCollapsed` now defaults to `true`.** Pass `false` for 0.11's
858
+ behaviour.
859
+ - **`ConnectSideNav` → `ConnectSidebarNav`**, **`ConnectUserMenu` →
860
+ `ConnectUserButton`**. Both old names remain as deprecated aliases.
861
+ `AppShell` keeps its name — the one export here without the `Connect`
862
+ prefix, as decided in 0.8.0.
863
+ - **New:** `secondaryNav` (a second nav group pinned to the foot of the rail),
864
+ `ConnectProfileModal` (the account modal with no trigger), and the rail's
865
+ own pieces — `ConnectSidebar`, `ConnectSidebarHeader`, `ConnectSidebarBody`,
866
+ `ConnectSidebarFooter`, `ConnectSidebarRow`, `ConnectShellProvider`,
867
+ `useConnectShell`, `sidebarRowClass`.
868
+ - **The workspace switcher moved into the rail's header** and is collapse-aware;
869
+ the account row sits at the foot with a circular avatar.
870
+ - **Fixed:** the closed Ask AI panel occupied 400px at `lg` and up on every page
871
+ with `askAi` on; sidebar contrast (the active row was **below WCAG AA** in
872
+ both schemes); the launcher and account rows looked permanently selected.
873
+
874
+ ### 0.11.0 — breaking
875
+
876
+ - **`ConnectUser.workspaceId`, `workspaceName` and `workspaceType` are
877
+ nullable.** They are `null` for exactly one thing: a **Connect platform
878
+ staff** session, which belongs to no workspace. If you never enable staff
879
+ access — it is off by default — this is a type narrow and nothing else.
880
+ `?? ""` is the wrong fix: decide what your tenanting does with a session that
881
+ has no tenant.
882
+ - **Connect platform staff sign-in**, `platformAccess` on `exchangeCode()` and
883
+ `getUser()`, `<ConnectPlatformGate>` and `useConnectPlatformAccess()`.
884
+
885
+ ### 0.10.0
886
+
887
+ - **`@cosmoops/connect-sdk/ui`** — `ConnectPageShell`, `ConnectSection`,
888
+ `ConnectEmptyState`, `ConnectStatusChip`, `ConnectSettingRow`. Deliberately
889
+ *not* a `"use client"` module, so a page shell does not become a client
890
+ boundary.
891
+ - `ConnectLinkButton`, `ConnectConfirmDialog`, `ConnectCursorPagination`,
892
+ `ConnectFilterTabs`, `ConnectAccountSessions` — built since 0.8 and reachable
893
+ from no entry point until now.
894
+ - `ConnectWorkspaceSwitcher`, `useConnectWorkspaces()`,
895
+ `useConnectWorkspaceMembers()`, and the `workspaces` resource on the server
896
+ client.
897
+
828
898
  ### 0.9.0
829
899
 
830
900
  - **Ask AI**, mounted by `AppShell` by default — a chat panel grounded in the
@@ -162,8 +162,8 @@ function checkEnv(root) {
162
162
  status: "fail",
163
163
  title: `${name} set`,
164
164
  detail: declaredEmpty
165
- ? `Declared but empty in .env.local. The value is in your application's Integration tab in Connect.`
166
- : `Not found in .env.local or the environment. Both values are in your application's Integration tab in Connect.`,
165
+ ? `Declared but empty in .env.local. The value is in your application's Connect client section in Connect.`
166
+ : `Not found in .env.local or the environment. Both values are in your application's Connect client section in Connect.`,
167
167
  };
168
168
  });
169
169
  }
@@ -176,7 +176,7 @@ function checkRedirectUri() {
176
176
  return {
177
177
  status: "warn",
178
178
  title: "Redirect URI registered in Connect",
179
- detail: "Not checkable from here. Confirm {your origin}/api/connect/callback is listed as a redirect URI in your application's Integration tab - including the deployed origin, not just http://localhost:3000.",
179
+ detail: "Not checkable from here. Confirm {your origin}/api/connect/callback is listed as a redirect URI in your application's Connect client section - including the deployed origin, not just http://localhost:3000.",
180
180
  };
181
181
  }
182
182
  const MARKER = { pass: " ok ", warn: " note ", fail: " FAIL " };
@@ -44,7 +44,7 @@ const PLANS_PAGE = `"use client";
44
44
  // with the other exports) whenever you want your own copy or layout.
45
45
  export { ConnectPlansPage as default } from "@cosmoops/connect-sdk/client";
46
46
  `;
47
- const ENV_TEMPLATE = `# CosmoOps Connect - values come from your application's Integration tab.
47
+ const ENV_TEMPLATE = `# CosmoOps Connect - values come from your application's Connect client section.
48
48
  CONNECT_API_KEY=
49
49
  CONNECT_CLIENT_ID=
50
50
  `;
@@ -111,7 +111,7 @@ function runInit(argv = []) {
111
111
  console.log(` ${marker} ${(0, node_path_1.relative)(root, target)} - ${scaffold.description}`);
112
112
  }
113
113
  const env = ensureEnv(root);
114
- console.log(` ${env === "skipped" ? "exists " : env === "appended" ? "updated" : "created"} .env.local - fill in the two values from your application's Integration tab`);
114
+ console.log(` ${env === "skipped" ? "exists " : env === "appended" ? "updated" : "created"} .env.local - fill in the two values from your application's Connect client section`);
115
115
  // The two steps a scaffolder must not do for you: editing an existing layout
116
116
  // means parsing and rewriting JSX someone else wrote, and the redirect URI
117
117
  // lives in Connect, not in this repo.
@@ -137,7 +137,7 @@ Three things left, all one-time:
137
137
  Tailwind roots its content scan inside node_modules and skips your app.
138
138
 
139
139
  3. Register {your origin}/api/connect/callback as a redirect URI in your
140
- application's Integration tab in Connect.
140
+ application's Connect client section in Connect.
141
141
 
142
142
  No next.config.ts changes needed - not even transpilePackages: dist/ is
143
143
  already ES2022 with "use client" hoisted, so Turbopack and webpack consume it
@@ -40,12 +40,31 @@ function formatDate(iso) {
40
40
  function useConnectAccessGate() {
41
41
  const { session, isLoading: sessionLoading } = (0, provider_js_1.useConnect)();
42
42
  const { data, isLoading: entitlementsLoading } = (0, provider_js_1.useConnectEntitlements)();
43
- if (sessionLoading || (session?.user && entitlementsLoading)) {
43
+ if (sessionLoading) {
44
44
  return { state: "loading", blocker: null, paymentRetry: null };
45
45
  }
46
46
  if (!session?.user) {
47
47
  return { state: "signed_out", blocker: null, paymentRetry: null };
48
48
  }
49
+ // Connect platform staff pass straight through, and this check has to come
50
+ // *before* the entitlements read below or the gate would hard-block them.
51
+ //
52
+ // Every state this gate can report is a fact about a workspace - suspended,
53
+ // no plan, wrong audience, awaiting approval, payment due. A staff session
54
+ // has no workspace at all (see `SessionAccess.staff`), so it has no subscription
55
+ // either, and `subscription_missing` would send an operator to your plan
56
+ // picker to buy a plan for a workspace that does not exist. "Nothing is
57
+ // blocking this session" is the truthful answer, not a lenient one.
58
+ //
59
+ // The entitlements request is still issued - hooks cannot be called
60
+ // conditionally - and the bridge answers it `403 no_workspace`. That costs
61
+ // one failed request per staff page load, and its result is ignored here.
62
+ if (session.staff) {
63
+ return { state: "clear", blocker: null, paymentRetry: null };
64
+ }
65
+ if (entitlementsLoading) {
66
+ return { state: "loading", blocker: null, paymentRetry: null };
67
+ }
49
68
  if (!data) {
50
69
  // Signed in, not loading, yet nothing came back - a failed read (see
51
70
  // `error` on `useConnectEntitlements`). Fail open rather than hard-block
@@ -12,7 +12,11 @@ const apps_grid_js_1 = require("./apps-grid.js");
12
12
  const ask_ai_js_1 = require("./ask-ai.js");
13
13
  const auth_buttons_js_1 = require("./auth-buttons.js");
14
14
  const logo_js_1 = require("./logo.js");
15
+ const motion_js_1 = require("./motion.js");
16
+ const sidebar_js_1 = require("./sidebar.js");
15
17
  const side_nav_js_1 = require("./side-nav.js");
18
+ const shell_context_js_1 = require("./shell-context.js");
19
+ const workspace_switcher_js_1 = require("./workspace-switcher.js");
16
20
  const user_menu_js_1 = require("./user-menu.js");
17
21
  const nav_js_1 = require("./nav.js");
18
22
  const use_active_path_js_1 = require("../use-active-path.js");
@@ -37,7 +41,7 @@ function collapsedSnapshot() {
37
41
  /**
38
42
  * No preference on the server, which is the point: `useSyncExternalStore` uses
39
43
  * this for the hydrating render and only then swaps in the real reading, so a
40
- * returning user's collapsed rail can never make the server's HTML and the
44
+ * returning user's rail state can never make the server's HTML and the
41
45
  * client's first pass disagree. Reading storage during render instead is a
42
46
  * hydration mismatch waiting for its first user.
43
47
  */
@@ -55,36 +59,21 @@ function subscribeCollapsed(onStoreChange) {
55
59
  };
56
60
  }
57
61
  /**
58
- * The sidebar's brand row: the lockup when expanded, the badge alone when not.
59
- */
60
- function BrandLink({ name, icon, homeHref, collapsed, renderLink, }) {
61
- return renderLink({
62
- href: homeHref,
63
- className: `flex items-center rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus ${collapsed ? "justify-center" : ""}`,
64
- // `ConnectLogo` names itself, but it does it with `role="img"` on a
65
- // `<span>`, which the `<a>` wrapping it does not inherit as its own name.
66
- "aria-label": `${name} home`,
67
- children: (0, jsx_runtime_1.jsx)(logo_js_1.ConnectLogo, { name: name, icon: icon, collapsed: collapsed }),
68
- });
69
- }
70
- /**
71
- * The application frame every CosmoOps application platform runs inside: a
72
- * collapsible sidebar, a top bar carrying the app launcher and the account
73
- * menu, and your pages in the middle.
62
+ * The application frame every CosmoOps application platform runs inside: one
63
+ * rail down the side holding the brand, the workspace, the navigation and the
64
+ * account, and your pages in the rest.
74
65
  *
75
66
  * This is the piece each partner was otherwise writing by hand, and writing
76
- * slightly differently - which is how a family of applications stops looking like
77
- * one product. Everything that should be identical across platforms (the
78
- * lockup, the rail geometry, the active-route treatment, the launcher, the
79
- * account menu, the access gate) lives here; everything that genuinely
80
- * differs is a prop:
67
+ * slightly differently - which is how a family of applications stops looking
68
+ * like one product. Everything that should be identical across platforms
69
+ * lives here; everything that genuinely differs is a prop:
81
70
  *
82
71
  * ```tsx
83
72
  * "use client";
84
73
  * import Link from "next/link";
85
74
  * import { usePathname } from "next/navigation";
86
75
  * import { AppShell } from "@cosmoops/connect-sdk/client";
87
- * import { LayoutDashboard, Truck } from "lucide-react";
76
+ * import { LayoutDashboard, Settings, Truck } from "lucide-react";
88
77
  *
89
78
  * export function Shell({ children }: { children: React.ReactNode }) {
90
79
  * return (
@@ -95,7 +84,10 @@ function BrandLink({ name, icon, homeHref, collapsed, renderLink, }) {
95
84
  * renderLink={(props) => <Link {...props} />}
96
85
  * nav={[
97
86
  * { label: "Overview", href: "/", icon: <LayoutDashboard className="size-5" /> },
98
- * { label: "Plans", href: "/plans", icon: <Truck className="size-5" />, gateExempt: true },
87
+ * { label: "Shipments", href: "/shipments", icon: <Truck className="size-5" /> },
88
+ * ]}
89
+ * secondaryNav={[
90
+ * { label: "Settings", href: "/settings", icon: <Settings className="size-5" /> },
99
91
  * ]}
100
92
  * >
101
93
  * {children}
@@ -104,16 +96,36 @@ function BrandLink({ name, icon, homeHref, collapsed, renderLink, }) {
104
96
  * }
105
97
  * ```
106
98
  *
99
+ * **There is no top bar.** There used to be one, carrying a page title, the
100
+ * launcher and the account menu - and it was a second, half-width strip of
101
+ * chrome saying things the rail was better placed to say. The launcher and
102
+ * the account row moved into the rail's footer, where they are one press from
103
+ * wherever the pointer already is; the page title moved into the page, where
104
+ * `ConnectPageShell` renders it next to that page's own actions and
105
+ * breadcrumbs. What is left is a single column of chrome and a full-height
106
+ * content pane, which is what every application this shell is modelled on
107
+ * settled at.
108
+ *
109
+ * The one exception is below `md`, where a slim bar carries the drawer
110
+ * trigger. A phone has no rail to put it in, and a navigation you cannot open
111
+ * is not a smaller navigation.
112
+ *
113
+ * **The rail starts collapsed.** `defaultCollapsed` is `true`: the icon rail
114
+ * is the resting state, and expanding is the deliberate act. That is the right
115
+ * default because the rail's job is to be *there*, not to be read - a person
116
+ * learns five glyphs in a day and then wants the 200px back for their data.
117
+ * A stored preference from the user's last visit always wins over it.
118
+ *
107
119
  * There is no `isSignedIn` branch anywhere in here, and there should be none
108
- * in yours: the auth buttons render nothing once a session exists, the user
109
- * menu renders nothing without one, and the launcher renders nothing when
120
+ * in yours: the auth buttons render nothing once a session exists, the account
121
+ * row renders nothing without one, and the launcher renders nothing when
110
122
  * signed out. The swap happens inside the components.
111
123
  *
112
124
  * It renders no provider of its own - mount `ConnectProvider` above it, once,
113
125
  * so a page with a shell, a plan table and a usage meter still makes one
114
126
  * session read.
115
127
  */
116
- function AppShell({ name, icon, homeHref = "/", nav, activePath, renderLink = side_nav_js_1.defaultRenderLink, title, headerActions, sidebarFooter, footer, defaultCollapsed = false, collapsed: controlledCollapsed, onCollapsedChange, collapsible = true, showAppsGrid = true, showAuthButtons = true, theme, onThemeChange, gate = true, gateExemptRoutes, askAi = true, className, contentClassName, children, }) {
128
+ function AppShell({ name, icon, homeHref = "/", nav, secondaryNav, activePath, renderLink = shell_context_js_1.defaultRenderLink, sidebarFooter, workspaceSwitcher, footer, defaultCollapsed = true, collapsed: controlledCollapsed, onCollapsedChange, collapsible = true, showAppsGrid = true, showAuthButtons = true, theme, onThemeChange, gate = true, gateExemptRoutes, askAi = true, className, contentClassName, children, }) {
117
129
  const { session, isLoading } = (0, provider_js_1.useConnect)();
118
130
  const pathname = (0, use_active_path_js_1.useActivePath)(activePath);
119
131
  const [mobileOpen, setMobileOpen] = (0, react_1.useState)(false);
@@ -123,8 +135,7 @@ function AppShell({ name, icon, homeHref = "/", nav, activePath, renderLink = si
123
135
  const stored = (0, react_1.useSyncExternalStore)(subscribeCollapsed, collapsedSnapshot, serverCollapsedSnapshot);
124
136
  const isControlled = controlledCollapsed !== undefined;
125
137
  const collapsed = collapsible ? (isControlled ? controlledCollapsed : (stored ?? defaultCollapsed)) : false;
126
- const toggleCollapsed = (0, react_1.useCallback)(() => {
127
- const next = !collapsed;
138
+ const setCollapsed = (0, react_1.useCallback)((next) => {
128
139
  onCollapsedChange?.(next);
129
140
  if (isControlled)
130
141
  return;
@@ -135,24 +146,47 @@ function AppShell({ name, icon, homeHref = "/", nav, activePath, renderLink = si
135
146
  // Not persisted - but the event below still moves the rail for this session.
136
147
  }
137
148
  window.dispatchEvent(new Event(COLLAPSED_EVENT));
138
- }, [collapsed, isControlled, onCollapsedChange]);
149
+ }, [isControlled, onCollapsedChange]);
139
150
  const closeMobile = (0, react_1.useCallback)(() => setMobileOpen(false), []);
151
+ // Connect platform staff see a different set of routes - see
152
+ // `ConnectNavItem.audience`. Derived from the session rather than passed in,
153
+ // so a host app never has to thread "is this an operator" through its own
154
+ // layout to get the right rail.
155
+ const isStaff = session?.staff === true;
156
+ const visibleNav = (0, react_1.useMemo)(() => (0, nav_js_1.filterNavForAudience)(nav, isStaff), [nav, isStaff]);
157
+ const visibleSecondaryNav = (0, react_1.useMemo)(() => (secondaryNav ? (0, nav_js_1.filterNavForAudience)(secondaryNav, isStaff) : undefined), [secondaryNav, isStaff]);
140
158
  const currentPath = (0, nav_js_1.normalizePath)(pathname);
141
- const exempt = [...(0, nav_js_1.gateExemptPaths)(nav), ...(gateExemptRoutes ?? []).map(nav_js_1.normalizePath)];
159
+ // Computed from the *unfiltered* nav on purpose: `gateExempt` is a fact
160
+ // about a route, not about who can see it, and reading it from the filtered
161
+ // list would quietly re-gate a route the moment it became staff-only. Both
162
+ // navs contribute - a `gateExempt` "Plans" route is just as often in the
163
+ // secondary group as the primary one.
164
+ const exempt = [
165
+ ...(0, nav_js_1.gateExemptPaths)(nav),
166
+ ...(secondaryNav ? (0, nav_js_1.gateExemptPaths)(secondaryNav) : []),
167
+ ...(gateExemptRoutes ?? []).map(nav_js_1.normalizePath),
168
+ ];
142
169
  const isExempt = exempt.some((path) => currentPath === path || currentPath.startsWith(`${path}/`));
143
170
  // A signed-out visitor has no readiness to check and nothing to be blocked
144
171
  // by; `ConnectAccessGate` passes them straight through, but mounting it at
145
172
  // all costs an entitlements read that cannot succeed.
146
173
  const signedOut = !isLoading && !session?.user;
147
174
  const gated = gate && !isExempt && !signedOut;
148
- const sideNav = (mobile) => ((0, jsx_runtime_1.jsx)(side_nav_js_1.ConnectSideNav, { nav: nav, activePath: pathname, renderLink: renderLink, collapsed: mobile ? false : collapsed, onNavigate: mobile ? closeMobile : undefined }));
149
- return ((0, jsx_runtime_1.jsxs)("div", { className: `flex h-screen bg-background ${className ?? ""}`, children: [(0, jsx_runtime_1.jsxs)("aside", { "aria-label": "Sidebar", className: `hidden shrink-0 flex-col border-r border-border bg-surface transition-[width] duration-200 ease-in-out will-change-[width] md:flex ${collapsed ? "w-16" : "w-64"}`, children: [(0, jsx_runtime_1.jsx)("div", { className: `flex h-16 shrink-0 items-center border-b border-border ${collapsed ? "justify-center px-2" : "px-4"}`, children: (0, jsx_runtime_1.jsx)(BrandLink, { name: name, icon: icon, homeHref: homeHref, collapsed: collapsed, renderLink: renderLink }) }), (0, jsx_runtime_1.jsx)("div", { className: `flex-1 overflow-y-auto py-4 ${collapsed ? "px-2" : "px-3"}`, children: sideNav(false) }), (0, jsx_runtime_1.jsxs)("div", { className: `shrink-0 border-t border-border ${collapsed ? "flex flex-col items-center gap-2 p-2" : "flex flex-col gap-2 p-3"}`, children: [!collapsed && sidebarFooter, collapsible && (
150
- // The button goes in as a *direct* child, the way `Modal` and
151
- // `Dropdown` take theirs. `Tooltip.Trigger` renders a wrapper
152
- // `<div>` and puts the props it is given on that div - so an
153
- // `aria-label` passed there names a div nobody can reach and
154
- // leaves this icon-only button with no accessible name at all.
155
- // The wrapper is for trigger elements React Aria cannot drive
156
- // directly; a HeroUI `Button` is not one.
157
- (0, jsx_runtime_1.jsxs)(react_2.Tooltip, { delay: 200, children: [(0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, variant: "tertiary", size: "sm", "aria-label": collapsed ? "Expand sidebar" : "Collapse sidebar", className: collapsed ? "" : "self-start", onPress: toggleCollapsed, children: collapsed ? (0, jsx_runtime_1.jsx)(lucide_react_1.PanelLeftOpen, { className: "size-4" }) : (0, jsx_runtime_1.jsx)(lucide_react_1.PanelLeftClose, { className: "size-4" }) }), (0, jsx_runtime_1.jsx)(react_2.Tooltip.Content, { placement: "right", children: collapsed ? "Expand sidebar" : "Collapse sidebar" })] }))] })] }), (0, jsx_runtime_1.jsx)(react_2.Drawer.Backdrop, { isOpen: mobileOpen, onOpenChange: setMobileOpen, children: (0, jsx_runtime_1.jsx)(react_2.Drawer.Content, { placement: "left", className: "w-72 max-w-[80vw]", children: (0, jsx_runtime_1.jsxs)(react_2.Drawer.Dialog, { "aria-label": "Navigation menu", children: [(0, jsx_runtime_1.jsx)(react_2.Drawer.CloseTrigger, {}), (0, jsx_runtime_1.jsx)(react_2.Drawer.Header, { children: (0, jsx_runtime_1.jsx)(react_2.Drawer.Heading, { "aria-label": name, children: (0, jsx_runtime_1.jsx)(logo_js_1.ConnectLogo, { name: name, icon: icon }) }) }), (0, jsx_runtime_1.jsx)(react_2.Drawer.Body, { children: sideNav(true) }), sidebarFooter && (0, jsx_runtime_1.jsx)(react_2.Drawer.Footer, { className: "flex flex-col items-stretch gap-3", children: sidebarFooter })] }) }) }), (0, jsx_runtime_1.jsxs)("div", { className: "flex min-w-0 flex-1 flex-col", children: [(0, jsx_runtime_1.jsxs)("header", { className: "flex h-16 shrink-0 items-center gap-3 border-b border-border bg-surface px-4 md:px-6", children: [(0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, variant: "tertiary", size: "sm", className: "md:hidden", "aria-label": "Open navigation menu", onPress: () => setMobileOpen(true), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Menu, { className: "size-5" }) }), typeof title === "string" ? ((0, jsx_runtime_1.jsx)("h1", { className: "truncate text-lg font-semibold text-foreground", children: title })) : (title), (0, jsx_runtime_1.jsxs)("div", { className: "ml-auto flex items-center gap-1", children: [headerActions, showAppsGrid && (0, jsx_runtime_1.jsx)(apps_grid_js_1.ConnectAppsGrid, {}), (0, jsx_runtime_1.jsx)(user_menu_js_1.ConnectUserMenu, { compact: true, theme: theme, onThemeChange: onThemeChange }), showAuthButtons && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(auth_buttons_js_1.ConnectLoginButton, { size: "sm" }), (0, jsx_runtime_1.jsx)(auth_buttons_js_1.ConnectRegisterButton, { size: "sm" })] }))] })] }), (0, jsx_runtime_1.jsx)("main", { className: "flex-1 overflow-y-auto", children: (0, jsx_runtime_1.jsxs)("div", { className: contentClassName ?? "mx-auto w-full max-w-6xl px-4 py-6 md:px-6 md:py-8", children: [gated ? (0, jsx_runtime_1.jsx)(access_gate_js_1.ConnectAccessGate, { children: children }) : children, footer] }) })] }), askAi && (0, jsx_runtime_1.jsx)(ask_ai_js_1.ConnectAskAi, { ...(askAi === true ? {} : askAi) })] }));
175
+ // Two shell contexts, one set of children. The drawer's is always expanded
176
+ // and closes itself on navigation; the rail's carries the real collapse
177
+ // state. Building them here rather than inside the sidebar is what lets the
178
+ // same `railContent` render in both places with no `mobile` prop threaded
179
+ // through every part.
180
+ const railShell = (0, react_1.useMemo)(() => ({ collapsed, collapsible, setCollapsed, renderLink, activePath }), [collapsed, collapsible, setCollapsed, renderLink, activePath]);
181
+ const drawerShell = (0, react_1.useMemo)(() => ({
182
+ collapsed: false,
183
+ collapsible: false,
184
+ setCollapsed: () => { },
185
+ renderLink,
186
+ activePath,
187
+ onNavigate: closeMobile,
188
+ }), [renderLink, activePath, closeMobile]);
189
+ const accountRow = signedOut ? (showAuthButtons && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-2", children: [(0, jsx_runtime_1.jsx)(auth_buttons_js_1.ConnectLoginButton, { size: "sm", fullWidth: true }), (0, jsx_runtime_1.jsx)(auth_buttons_js_1.ConnectRegisterButton, { size: "sm", fullWidth: true })] }))) : ((0, jsx_runtime_1.jsx)(user_menu_js_1.ConnectUserButton, { variant: "row", theme: theme, onThemeChange: onThemeChange }));
190
+ const railContent = ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(sidebar_js_1.ConnectSidebarHeader, { name: name, icon: icon, homeHref: homeHref }), workspaceSwitcher !== false && ((0, jsx_runtime_1.jsx)("div", { className: "shrink-0 px-3 pb-1", children: (0, jsx_runtime_1.jsx)(workspace_switcher_js_1.ConnectWorkspaceSwitcher, {}) })), (0, jsx_runtime_1.jsx)(sidebar_js_1.ConnectSidebarBody, { children: (0, jsx_runtime_1.jsx)(side_nav_js_1.ConnectSidebarNav, { nav: visibleNav }) }), (visibleSecondaryNav || sidebarFooter || showAppsGrid) && ((0, jsx_runtime_1.jsxs)(sidebar_js_1.ConnectSidebarFooter, { separated: false, children: [visibleSecondaryNav && (0, jsx_runtime_1.jsx)(side_nav_js_1.ConnectSidebarNav, { nav: visibleSecondaryNav }), sidebarFooter, showAppsGrid && (0, jsx_runtime_1.jsx)(apps_grid_js_1.ConnectAppsGrid, { variant: "row" })] })), accountRow && (0, jsx_runtime_1.jsx)(sidebar_js_1.ConnectSidebarFooter, { children: accountRow })] }));
191
+ return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsxs)("div", { className: `flex h-screen bg-background ${className ?? ""}`, children: [(0, jsx_runtime_1.jsx)(shell_context_js_1.ConnectShellProvider, { value: railShell, children: (0, jsx_runtime_1.jsx)(sidebar_js_1.ConnectSidebar, { children: railContent }) }), (0, jsx_runtime_1.jsx)(react_2.Drawer.Backdrop, { isOpen: mobileOpen, onOpenChange: setMobileOpen, children: (0, jsx_runtime_1.jsx)(react_2.Drawer.Content, { placement: "left", className: "w-[min(20rem,85vw)]", children: (0, jsx_runtime_1.jsx)(react_2.Drawer.Dialog, { "aria-label": "Navigation menu", className: "p-0", children: (0, jsx_runtime_1.jsx)(shell_context_js_1.ConnectShellProvider, { value: drawerShell, children: (0, jsx_runtime_1.jsx)("div", { className: "flex h-full flex-col", children: railContent }) }) }) }) }), (0, jsx_runtime_1.jsxs)("div", { className: "flex min-w-0 flex-1 flex-col", children: [(0, jsx_runtime_1.jsxs)("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border bg-surface px-3 md:hidden", children: [(0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, variant: "tertiary", size: "sm", "aria-label": "Open navigation menu", onPress: () => setMobileOpen(true), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Menu, { className: "size-5" }) }), (0, jsx_runtime_1.jsx)(logo_js_1.ConnectLogo, { name: name, icon: icon })] }), (0, jsx_runtime_1.jsx)("main", { className: "flex-1 overflow-y-auto", children: (0, jsx_runtime_1.jsxs)("div", { className: contentClassName ?? "mx-auto w-full max-w-6xl px-4 py-6 md:px-8 md:py-8", children: [gated ? (0, jsx_runtime_1.jsx)(access_gate_js_1.ConnectAccessGate, { children: children }) : children, footer] }) })] }), askAi && (0, jsx_runtime_1.jsx)(ask_ai_js_1.ConnectAskAi, { ...(askAi === true ? {} : askAi) })] }) }));
158
192
  }
@@ -7,6 +7,7 @@ const react_1 = require("@heroui/react");
7
7
  const lucide_react_1 = require("lucide-react");
8
8
  const provider_js_1 = require("../provider.js");
9
9
  const motion_js_1 = require("./motion.js");
10
+ const shell_context_js_1 = require("./shell-context.js");
10
11
  function AppTile({ app }) {
11
12
  const disabled = app.launchUrl === null;
12
13
  const body = ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("span", { className: "relative flex size-12 items-center justify-center overflow-hidden rounded-xl bg-surface-secondary", children: [app.iconUrl ? (
@@ -33,13 +34,13 @@ function AppTile({ app }) {
33
34
  * Renders nothing when signed out: a launcher with nothing to launch is just a
34
35
  * button that opens an empty box.
35
36
  */
36
- function ConnectAppsGrid({ className, label = "Switch app" }) {
37
+ function ConnectAppsGrid({ className, label = "Switch app", variant = "row" }) {
37
38
  const { session, isLoading: sessionLoading } = (0, provider_js_1.useConnect)();
38
39
  const { data, isLoading, error } = (0, provider_js_1.useConnectApps)();
39
40
  if (sessionLoading || !session?.user)
40
41
  return null;
41
42
  const apps = data?.apps ?? [];
42
- return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsxs)(react_1.Modal, { children: [(0, jsx_runtime_1.jsx)(react_1.Button, { isIconOnly: true, variant: "primary", "aria-label": label, className: className, children: (0, jsx_runtime_1.jsx)(lucide_react_1.LayoutGrid, { className: "size-5" }) }), (0, jsx_runtime_1.jsx)(react_1.Modal.Backdrop, { children: (0, jsx_runtime_1.jsx)(react_1.Modal.Container, { children: (0, jsx_runtime_1.jsxs)(react_1.Modal.Dialog, { className: "sm:max-w-md", children: [(0, jsx_runtime_1.jsx)(react_1.Modal.CloseTrigger, {}), (0, jsx_runtime_1.jsx)(react_1.Modal.Header, { children: (0, jsx_runtime_1.jsx)(react_1.Modal.Heading, { children: "Your apps" }) }), (0, jsx_runtime_1.jsxs)(react_1.Modal.Body, { children: [isLoading && ((0, jsx_runtime_1.jsx)("div", { className: "grid grid-cols-3 gap-2", children: Array.from({ length: 6 }, (_, index) => ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col items-center gap-2 p-3", children: [(0, jsx_runtime_1.jsx)(react_1.Skeleton, { className: "size-12 rounded-xl" }), (0, jsx_runtime_1.jsx)(react_1.Skeleton, { className: "h-3 w-14" })] }, index))) })), !isLoading && error && ((0, jsx_runtime_1.jsx)("p", { className: "py-6 text-center text-sm text-danger", children: "Could not load your apps." })), !isLoading && !error && apps.length === 0 && ((0, jsx_runtime_1.jsx)("p", { className: "py-6 text-center text-sm text-muted", children: "No other apps yet. Anything this workspace subscribes to shows up here." })), !isLoading && !error && apps.length > 0 && ((0, jsx_runtime_1.jsx)(motion_js_1.m.div, { className: "grid grid-cols-3 gap-2", variants: { show: { transition: { staggerChildren: 0.04 } } }, initial: "hidden", animate: "show", children: apps.map((app) => ((0, jsx_runtime_1.jsx)(motion_js_1.m.div, { variants: { hidden: { opacity: 0, y: 8 }, show: { opacity: 1, y: 0, transition: motion_js_1.ENTER } }, children: (0, jsx_runtime_1.jsx)(AppTile, { app: app }) }, app.applicationId))) }))] }), (0, jsx_runtime_1.jsx)(react_1.Modal.Footer, { children: (0, jsx_runtime_1.jsx)(ConnectBrowseAllLink, {}) })] }) }) })] }) }));
43
+ return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsxs)(react_1.Modal, { children: [variant === "row" ? ((0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "tertiary", "aria-label": label, className: (0, shell_context_js_1.sidebarRowClass)({ className }), children: [(0, jsx_runtime_1.jsx)("span", { className: "flex size-5 shrink-0 items-center justify-center", children: (0, jsx_runtime_1.jsx)(lucide_react_1.LayoutGrid, { className: "size-5" }) }), (0, jsx_runtime_1.jsx)(shell_context_js_1.SidebarLabel, { children: label })] })) : ((0, jsx_runtime_1.jsx)(react_1.Button, { isIconOnly: true, variant: "primary", "aria-label": label, className: className, children: (0, jsx_runtime_1.jsx)(lucide_react_1.LayoutGrid, { className: "size-5" }) })), (0, jsx_runtime_1.jsx)(react_1.Modal.Backdrop, { children: (0, jsx_runtime_1.jsx)(react_1.Modal.Container, { children: (0, jsx_runtime_1.jsxs)(react_1.Modal.Dialog, { className: "sm:max-w-md", children: [(0, jsx_runtime_1.jsx)(react_1.Modal.CloseTrigger, {}), (0, jsx_runtime_1.jsx)(react_1.Modal.Header, { children: (0, jsx_runtime_1.jsx)(react_1.Modal.Heading, { children: "Your apps" }) }), (0, jsx_runtime_1.jsxs)(react_1.Modal.Body, { children: [isLoading && ((0, jsx_runtime_1.jsx)("div", { className: "grid grid-cols-3 gap-2", children: Array.from({ length: 6 }, (_, index) => ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col items-center gap-2 p-3", children: [(0, jsx_runtime_1.jsx)(react_1.Skeleton, { className: "size-12 rounded-xl" }), (0, jsx_runtime_1.jsx)(react_1.Skeleton, { className: "h-3 w-14" })] }, index))) })), !isLoading && error && ((0, jsx_runtime_1.jsx)("p", { className: "py-6 text-center text-sm text-danger", children: "Could not load your apps." })), !isLoading && !error && apps.length === 0 && ((0, jsx_runtime_1.jsx)("p", { className: "py-6 text-center text-sm text-muted", children: "No other apps yet. Anything this workspace subscribes to shows up here." })), !isLoading && !error && apps.length > 0 && ((0, jsx_runtime_1.jsx)(motion_js_1.m.div, { className: "grid grid-cols-3 gap-2", variants: { show: { transition: { staggerChildren: 0.04 } } }, initial: "hidden", animate: "show", children: apps.map((app) => ((0, jsx_runtime_1.jsx)(motion_js_1.m.div, { variants: { hidden: { opacity: 0, y: 8 }, show: { opacity: 1, y: 0, transition: motion_js_1.ENTER } }, children: (0, jsx_runtime_1.jsx)(AppTile, { app: app }) }, app.applicationId))) }))] }), (0, jsx_runtime_1.jsx)(react_1.Modal.Footer, { children: (0, jsx_runtime_1.jsx)(ConnectBrowseAllLink, {}) })] }) }) })] }) }));
43
44
  }
44
45
  function ConnectBrowseAllLink() {
45
46
  const { appUrl } = (0, provider_js_1.useConnect)();
@@ -9,7 +9,26 @@ const lucide_react_1 = require("lucide-react");
9
9
  const streamdown_1 = require("streamdown");
10
10
  const provider_js_1 = require("../provider.js");
11
11
  const ai_widgets_js_1 = require("./ai-widgets.js");
12
+ /**
13
+ * The panel's open width. The *inner* pane always carries it, so the content
14
+ * keeps its layout while the outer shell's width animates to zero around it.
15
+ */
12
16
  const PANEL_WIDTH = "w-full lg:w-[400px]";
17
+ /**
18
+ * The outer shell's width, which is the thing that animates.
19
+ *
20
+ * Two mutually exclusive branches rather than `PANEL_WIDTH` plus an
21
+ * `lg:w-0` override, because that override never applied: `lg:w-[400px]` and
22
+ * `lg:w-0` are the same utility at the same breakpoint, so the winner is
23
+ * whichever Tailwind emits later - and it emits arbitrary values after scale
24
+ * values. The closed panel therefore measured a full 400px at `lg` and up and
25
+ * sat there permanently, eating that much of the content pane on every page
26
+ * with `askAi` on. Only the `translate-x` hid it below `lg`, which is why this
27
+ * looked correct on a phone and wrong on a laptop.
28
+ */
29
+ function panelShellWidth(isOpen) {
30
+ return isOpen ? PANEL_WIDTH : "w-full lg:w-0 lg:overflow-hidden lg:border-s-0";
31
+ }
13
32
  /**
14
33
  * `blurIn` over the default `fadeIn`: Gemini arrives in bursts rather than an
15
34
  * even token drip, and a blur masks a batch landing at once where a plain
@@ -328,7 +347,7 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
328
347
  setInput("");
329
348
  void send(text);
330
349
  };
331
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [!isOpen && ((0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, variant: "primary", "aria-label": title, onPress: () => setIsOpen(true), className: "fixed bottom-4 end-4 z-30 size-12 rounded-full shadow-lg sm:bottom-6 sm:end-6", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Sparkles, { className: "size-5" }) })), (0, jsx_runtime_1.jsx)("div", { className: `fixed inset-0 z-40 bg-backdrop transition-opacity lg:hidden ${isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`, onClick: () => setIsOpen(false), "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("aside", { "aria-hidden": !isOpen, className: `fixed inset-y-0 end-0 z-50 flex flex-col border-s border-border bg-surface shadow-xl transition-transform duration-300 ease-in-out ${PANEL_WIDTH} ${isOpen ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"} lg:static lg:z-auto lg:translate-x-0 lg:shadow-none lg:transition-[width] ${isOpen ? "" : "lg:w-0 lg:overflow-hidden lg:border-s-0"}`, children: (0, jsx_runtime_1.jsxs)("div", { className: `flex h-full shrink-0 flex-col ${PANEL_WIDTH}`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 border-b border-border px-4 py-3", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Sparkles, { className: "size-4 shrink-0 text-accent" }), (0, jsx_runtime_1.jsx)("span", { className: "flex-1 text-sm font-semibold", children: title }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Close", onPress: () => setIsOpen(false), children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4" }) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex-1 space-y-3 overflow-y-auto p-4", children: [turns.length === 0 && (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: hint }), rendered, isBusy && lastTurn?.role === "user" && ((0, jsx_runtime_1.jsxs)("div", { className: "rounded-md border border-border bg-accent/5 p-2.5 text-sm", children: [(0, jsx_runtime_1.jsx)("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: assistantName }), (0, jsx_runtime_1.jsx)(ThinkingShimmer, {})] })), error && (0, jsx_runtime_1.jsx)("p", { className: "text-xs text-danger", children: error }), (0, jsx_runtime_1.jsx)("div", { ref: bottomRef })] }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "flex gap-2 border-t border-border p-3", children: [(0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", className: "flex-1", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { className: "sr-only", children: placeholder }), (0, jsx_runtime_1.jsx)(react_2.Input, { placeholder: placeholder, value: input, onChange: (event) => setInput(event.currentTarget.value), disabled: isBusy })] }), isBusy ? ((0, jsx_runtime_1.jsx)(react_2.Button, { type: "button", isIconOnly: true, variant: "secondary", "aria-label": "Stop generating", onPress: stop, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Square, { className: "size-3.5" }) })) : ((0, jsx_runtime_1.jsx)(react_2.Button, { type: "submit", isIconOnly: true, variant: "primary", "aria-label": "Send", isDisabled: !input.trim(), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Send, { className: "size-3.5" }) }))] })] }) })] }));
350
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [!isOpen && ((0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, variant: "primary", "aria-label": title, onPress: () => setIsOpen(true), className: "fixed bottom-4 end-4 z-30 size-12 rounded-full shadow-lg sm:bottom-6 sm:end-6", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Sparkles, { className: "size-5" }) })), (0, jsx_runtime_1.jsx)("div", { className: `fixed inset-0 z-40 bg-backdrop transition-opacity lg:hidden ${isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`, onClick: () => setIsOpen(false), "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("aside", { "aria-hidden": !isOpen, className: `fixed inset-y-0 end-0 z-50 flex flex-col border-s border-border bg-surface shadow-xl transition-transform duration-300 ease-in-out ${panelShellWidth(isOpen)} ${isOpen ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"} lg:static lg:z-auto lg:translate-x-0 lg:shadow-none lg:transition-[width]`, children: (0, jsx_runtime_1.jsxs)("div", { className: `flex h-full shrink-0 flex-col ${PANEL_WIDTH}`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 border-b border-border px-4 py-3", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Sparkles, { className: "size-4 shrink-0 text-accent" }), (0, jsx_runtime_1.jsx)("span", { className: "flex-1 text-sm font-semibold", children: title }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Close", onPress: () => setIsOpen(false), children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4" }) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex-1 space-y-3 overflow-y-auto p-4", children: [turns.length === 0 && (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: hint }), rendered, isBusy && lastTurn?.role === "user" && ((0, jsx_runtime_1.jsxs)("div", { className: "rounded-md border border-border bg-accent/5 p-2.5 text-sm", children: [(0, jsx_runtime_1.jsx)("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: assistantName }), (0, jsx_runtime_1.jsx)(ThinkingShimmer, {})] })), error && (0, jsx_runtime_1.jsx)("p", { className: "text-xs text-danger", children: error }), (0, jsx_runtime_1.jsx)("div", { ref: bottomRef })] }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "flex gap-2 border-t border-border p-3", children: [(0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", className: "flex-1", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { className: "sr-only", children: placeholder }), (0, jsx_runtime_1.jsx)(react_2.Input, { placeholder: placeholder, value: input, onChange: (event) => setInput(event.currentTarget.value), disabled: isBusy })] }), isBusy ? ((0, jsx_runtime_1.jsx)(react_2.Button, { type: "button", isIconOnly: true, variant: "secondary", "aria-label": "Stop generating", onPress: stop, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Square, { className: "size-3.5" }) })) : ((0, jsx_runtime_1.jsx)(react_2.Button, { type: "submit", isIconOnly: true, variant: "primary", "aria-label": "Send", isDisabled: !input.trim(), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Send, { className: "size-3.5" }) }))] })] }) })] }));
332
351
  }
333
352
  /** Hoisted for the same reference-equality reason as `ANIMATED` - Streamdown compares it by identity. */
334
353
  const LINK_SAFETY = { enabled: false };