@cosmoops/connect-sdk 0.25.0 → 0.26.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 (63) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/cjs/client/components/access-gate.js +31 -5
  3. package/dist/cjs/client/components/account-sessions.js +4 -3
  4. package/dist/cjs/client/components/app-shell.js +2 -1
  5. package/dist/cjs/client/components/booking-calendar.js +2 -1
  6. package/dist/cjs/client/components/cursor-pagination.js +3 -2
  7. package/dist/cjs/client/components/filter-tabs.js +23 -5
  8. package/dist/cjs/client/components/grievance-notice.js +2 -1
  9. package/dist/cjs/client/components/logo.js +3 -2
  10. package/dist/cjs/client/components/one-tap.js +2 -1
  11. package/dist/cjs/client/components/plans.js +3 -2
  12. package/dist/cjs/client/components/readiness.js +4 -3
  13. package/dist/cjs/client/components/shell-context.js +2 -1
  14. package/dist/cjs/client/components/side-nav.js +2 -1
  15. package/dist/cjs/client/components/sidebar.js +2 -1
  16. package/dist/cjs/client/components/subscription.js +5 -4
  17. package/dist/cjs/client/components/usage-meter.js +2 -1
  18. package/dist/cjs/client/components/user-menu.js +3 -2
  19. package/dist/cjs/cn.js +53 -0
  20. package/dist/cjs/server/client.js +1 -1
  21. package/dist/cjs/store.js +14 -1
  22. package/dist/cjs/ui/empty-state.js +2 -1
  23. package/dist/cjs/ui/index.js +8 -1
  24. package/dist/cjs/ui/page-header.js +2 -1
  25. package/dist/cjs/ui/page-shell.js +3 -2
  26. package/dist/cjs/ui/section.js +2 -1
  27. package/dist/cjs/ui/setting-row.js +2 -1
  28. package/dist/esm/client/components/access-gate.js +33 -7
  29. package/dist/esm/client/components/account-sessions.js +4 -3
  30. package/dist/esm/client/components/app-shell.js +2 -1
  31. package/dist/esm/client/components/booking-calendar.js +2 -1
  32. package/dist/esm/client/components/cursor-pagination.js +3 -2
  33. package/dist/esm/client/components/filter-tabs.js +23 -5
  34. package/dist/esm/client/components/grievance-notice.js +2 -1
  35. package/dist/esm/client/components/logo.js +3 -2
  36. package/dist/esm/client/components/one-tap.js +2 -1
  37. package/dist/esm/client/components/plans.js +3 -2
  38. package/dist/esm/client/components/readiness.js +4 -3
  39. package/dist/esm/client/components/shell-context.js +2 -1
  40. package/dist/esm/client/components/side-nav.js +2 -1
  41. package/dist/esm/client/components/sidebar.js +2 -1
  42. package/dist/esm/client/components/subscription.js +5 -4
  43. package/dist/esm/client/components/usage-meter.js +2 -1
  44. package/dist/esm/client/components/user-menu.js +3 -2
  45. package/dist/esm/cn.js +48 -0
  46. package/dist/esm/server/client.js +1 -1
  47. package/dist/esm/store.js +14 -1
  48. package/dist/esm/ui/empty-state.js +2 -1
  49. package/dist/esm/ui/index.js +6 -0
  50. package/dist/esm/ui/page-header.js +2 -1
  51. package/dist/esm/ui/page-shell.js +3 -2
  52. package/dist/esm/ui/section.js +2 -1
  53. package/dist/esm/ui/setting-row.js +2 -1
  54. package/dist/theme.css +53 -0
  55. package/dist/types/client/components/access-gate.d.ts +35 -4
  56. package/dist/types/client/components/cursor-pagination.d.ts +6 -1
  57. package/dist/types/client/components/filter-tabs.d.ts +9 -3
  58. package/dist/types/cn.d.ts +48 -0
  59. package/dist/types/server/client.d.ts +1 -1
  60. package/dist/types/store.d.ts +6 -0
  61. package/dist/types/ui/index.d.ts +6 -0
  62. package/dist/types/ui/page-shell.d.ts +7 -1
  63. package/package.json +1 -1
@@ -5,6 +5,7 @@ import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
5
5
  import { ConnectLogo } from "./logo.js";
6
6
  import { EASE_OUT_QUART, MotionProvider, m } from "./motion.js";
7
7
  import { RAIL_COLLAPSED, RAIL_EXPANDED, SidebarLabel, sidebarRowClass, useConnectShell } from "./shell-context.js";
8
+ import { cn } from "../../cn.js";
8
9
  /**
9
10
  * The rail's own transition.
10
11
  *
@@ -43,7 +44,7 @@ export function ConnectSidebar({ children, label = "Sidebar", className }) {
43
44
  // from these parts by hand gets a sidebar that jumps between widths
44
45
  // instead of animating - a failure that looks like a styling bug and is
45
46
  // really a missing provider.
46
- _jsx(MotionProvider, { children: _jsx(m.aside, { "aria-label": label, className: `hidden shrink-0 flex-col overflow-hidden border-e border-border bg-surface md:flex ${className ?? ""}`,
47
+ _jsx(MotionProvider, { children: _jsx(m.aside, { "aria-label": label, className: cn("hidden shrink-0 flex-col overflow-hidden border-e border-border bg-surface md:flex", className),
47
48
  // `initial={false}` is what stops the rail sweeping open on first paint
48
49
  // for a returning user whose stored preference is "collapsed".
49
50
  initial: false, animate: { width: collapsed ? RAIL_COLLAPSED : RAIL_EXPANDED }, transition: RAIL_TRANSITION, children: children }) }));
@@ -6,6 +6,7 @@ import { useConnect, useConnectEntitlements } from "../provider.js";
6
6
  import { m, MotionProvider } from "./motion.js";
7
7
  import { formatPrice, intervalLabel } from "./plan-pricing.js";
8
8
  import { ConnectLinkButton } from "./link-button.js";
9
+ import { cn } from "../../cn.js";
9
10
  /** Where the plan catalogue lives in the host app. Overridable, because not every app mounts it at `/plans`. */
10
11
  const DEFAULT_PLANS_HREF = "/plans";
11
12
  function formatDate(iso) {
@@ -74,7 +75,7 @@ export function ConnectSubscriptionCard({ plansHref, className }) {
74
75
  const { appUrl } = useConnect();
75
76
  const { isLoading, detail, plan, state } = useSubscriptionState();
76
77
  if (isLoading)
77
- return _jsx(Skeleton, { className: `h-48 rounded-xl ${className ?? ""}` });
78
+ return _jsx(Skeleton, { className: cn("h-48 rounded-xl", className) });
78
79
  if (!detail || !plan)
79
80
  return _jsx(ConnectNoSubscriptionCard, { plansHref: plansHref, className: className });
80
81
  return (_jsx(MotionProvider, { children: _jsx(m.div, { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.2 }, children: _jsxs(Card, { className: className, children: [_jsxs(Card.Header, { children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx(Card.Title, { children: plan.name }), _jsx(SubscriptionChip, { state: state })] }), _jsxs(Card.Description, { children: [formatPrice(plan.price), " + ", plan.gstRate, "% GST per", " ", intervalLabel(plan.billingInterval).per] })] }), _jsxs(Card.Content, { className: "flex flex-col gap-2 text-sm", children: [_jsx(NextEvent, { detail: detail, state: state }), _jsx(ConnectSeatUsage, { plan: plan, detail: detail })] }), _jsxs(Card.Footer, { className: "flex flex-wrap gap-2", children: [_jsxs(Button, { variant: "secondary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${detail.id}`, "_blank", "noopener,noreferrer"), children: ["Manage on Connect", _jsx(ExternalLink, { className: "size-3.5" })] }), _jsx(ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Change plan" })] })] }) }) }));
@@ -124,7 +125,7 @@ export function ConnectSeatUsage({ plan, detail, className, }) {
124
125
  return null;
125
126
  const included = plan.seats.included ?? 0;
126
127
  const over = detail.seatCount - included;
127
- return (_jsxs("span", { className: `flex items-center gap-2 text-muted ${className ?? ""}`, children: [_jsx(Users, { className: "size-4 shrink-0", "aria-hidden": "true" }), detail.seatCount, " of ", included, " seat", included === 1 ? "" : "s", " used", over > 0 &&
128
+ return (_jsxs("span", { className: cn("flex items-center gap-2 text-muted", className), children: [_jsx(Users, { className: "size-4 shrink-0", "aria-hidden": "true" }), detail.seatCount, " of ", included, " seat", included === 1 ? "" : "s", " used", over > 0 &&
128
129
  (plan.seats.model === "per-seat"
129
130
  ? ` - ${over} extra at ${formatPrice(plan.seats.pricePerExtraSeat ?? 0)} each`
130
131
  : ` - ${over} over the plan's limit`)] }));
@@ -146,7 +147,7 @@ export function ConnectTrialBanner({ plansHref, className }) {
146
147
  const { detail, state } = useSubscriptionState();
147
148
  if (state !== "trialing" || !detail)
148
149
  return null;
149
- return (_jsxs("div", { className: `flex flex-wrap items-center justify-between gap-3 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent ${className ?? ""}`, children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(Clock, { className: "size-4 shrink-0", "aria-hidden": "true" }), "You're on a free trial", detail.nextBillingDate ? `. Your first charge is on ${formatDate(detail.nextBillingDate)}.` : "."] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Compare plans" }), _jsxs(Button, { variant: "secondary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${detail.id}`, "_blank", "noopener,noreferrer"), children: ["Manage", _jsx(ExternalLink, { className: "size-3.5" })] })] })] }));
150
+ return (_jsxs("div", { className: cn("flex flex-wrap items-center justify-between gap-3 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent", className), children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(Clock, { className: "size-4 shrink-0", "aria-hidden": "true" }), "You're on a free trial", detail.nextBillingDate ? `. Your first charge is on ${formatDate(detail.nextBillingDate)}.` : "."] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Compare plans" }), _jsxs(Button, { variant: "secondary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${detail.id}`, "_blank", "noopener,noreferrer"), children: ["Manage", _jsx(ExternalLink, { className: "size-3.5" })] })] })] }));
150
151
  }
151
152
  /**
152
153
  * Renders `children` for a subscribed workspace, and the "pick a plan" card for
@@ -166,7 +167,7 @@ export function ConnectSubscriptionGate({ children, plansHref, fallback, loading
166
167
  const { session, isLoading: sessionLoading } = useConnect();
167
168
  const { isLoading, state } = useSubscriptionState();
168
169
  if (sessionLoading || (session?.user && isLoading)) {
169
- return _jsx(_Fragment, { children: loading ?? _jsx(Skeleton, { className: `h-48 rounded-xl ${className ?? ""}` }) });
170
+ return _jsx(_Fragment, { children: loading ?? _jsx(Skeleton, { className: cn("h-48 rounded-xl", className) }) });
170
171
  }
171
172
  if (!session?.user)
172
173
  return _jsx(_Fragment, { children: fallback ?? null });
@@ -5,6 +5,7 @@ import { AlertTriangle } from "lucide-react";
5
5
  import { useConnect, useConnectAddonUsage } from "../provider.js";
6
6
  import { m, METER_SPRING, MotionProvider } from "./motion.js";
7
7
  import { formatPrice } from "./plan-pricing.js";
8
+ import { cn } from "../../cn.js";
8
9
  function formatUnits(value, unit) {
9
10
  return `${value.toLocaleString("en-IN")} ${unit}`;
10
11
  }
@@ -32,7 +33,7 @@ export function ConnectUsageMeter({ addonIds, hideWhenEmpty = true, title = "Usa
32
33
  if (!session?.user)
33
34
  return null;
34
35
  if (isLoading)
35
- return _jsx(Skeleton, { className: `h-40 rounded-xl ${className ?? ""}` });
36
+ return _jsx(Skeleton, { className: cn("h-40 rounded-xl", className) });
36
37
  if (error || !data)
37
38
  return null;
38
39
  // Defended here as well as in `billing.getAddonUsage`, and deliberately so:
@@ -9,6 +9,7 @@ import { ConnectAccountSessions } from "./account-sessions.js";
9
9
  import { m } from "./motion.js";
10
10
  import { SidebarLabel, sidebarRowClass, useConnectShell } from "./shell-context.js";
11
11
  import { ICON, MEDALLION, PANEL_FILL } from "./surfaces.js";
12
+ import { cn } from "../../cn.js";
12
13
  const THEME_OPTIONS = [
13
14
  { value: "light", label: "Light", icon: Sun },
14
15
  { value: "dark", label: "Dark", icon: Moon },
@@ -189,7 +190,7 @@ export function ConnectUserButton({ variant = "row", onSignOut, className, theme
189
190
  const { session, isLoading } = useConnect();
190
191
  const { collapsed } = useConnectShell();
191
192
  if (isLoading) {
192
- return (_jsxs("div", { className: `flex h-10 items-center gap-3 ${variant === "row" ? "ps-[4px]" : ""} ${className ?? ""}`, children: [_jsx(Skeleton, { className: "size-8 shrink-0 rounded-full" }), variant !== "compact" && !collapsed && _jsx(Skeleton, { className: "h-4 w-24" })] }));
193
+ return (_jsxs("div", { className: cn("flex h-10 items-center gap-3", variant === "row" ? "ps-[4px]" : "", className), children: [_jsx(Skeleton, { className: "size-8 shrink-0 rounded-full" }), variant !== "compact" && !collapsed && _jsx(Skeleton, { className: "h-4 w-24" })] }));
193
194
  }
194
195
  const user = session?.user;
195
196
  if (!user)
@@ -199,7 +200,7 @@ export function ConnectUserButton({ variant = "row", onSignOut, className, theme
199
200
  // `ps-1` against the default trailing padding is optical, not
200
201
  // sloppy: a circular avatar needs less inset than a text label to
201
202
  // look evenly seated in the button.
202
- className: `gap-2 ps-1 ${className ?? ""}`, children: [_jsx(UserAvatar, { user: user, size: "sm" }), variant === "inline" && _jsx("span", { className: "text-sm font-medium text-foreground", children: label })] })), _jsx(ProfileSurface, { onSignOut: onSignOut, theme: theme, onThemeChange: onThemeChange })] }));
203
+ className: cn("gap-2 ps-1", className), children: [_jsx(UserAvatar, { user: user, size: "sm" }), variant === "inline" && _jsx("span", { className: "text-sm font-medium text-foreground", children: label })] })), _jsx(ProfileSurface, { onSignOut: onSignOut, theme: theme, onThemeChange: onThemeChange })] }));
203
204
  }
204
205
  /**
205
206
  * @deprecated Renamed to `ConnectUserButton` in 0.12 - it is one button that
package/dist/esm/cn.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Merge class names so a caller's Tailwind utility actually wins.
3
+ *
4
+ * Every component in this package carries default utilities, and a partner
5
+ * styling one reaches for `className`. Concatenating the two does not do what
6
+ * it looks like it does:
7
+ *
8
+ * ```tsx
9
+ * // ConnectBookingCalendar with className="gap-8"
10
+ * <div className={`flex flex-col gap-4 ${className}`} />
11
+ * // -> class="flex flex-col gap-4 gap-8"
12
+ * ```
13
+ *
14
+ * Both rules are now in the stylesheet and both match, so the winner is
15
+ * decided by **source order in the generated CSS**, not by the order they
16
+ * appear in the attribute. Tailwind sorts its output by its own internal
17
+ * ordering, so whether `gap-8` beats `gap-4` depends on which utility Tailwind
18
+ * happens to emit later - `p-8` over `px-3 py-2` lands one way, `gap-8` over
19
+ * `gap-4` the other. An override that works on one prop and silently does
20
+ * nothing on the next is worse than one that never works, because nobody
21
+ * suspects the mechanism.
22
+ *
23
+ * `cn` resolves it by *removing* the conflicting default rather than adding to
24
+ * it - `cn("flex flex-col gap-4", "gap-8")` is `"flex flex-col gap-8"`, and
25
+ * `cn("px-3 py-2", "p-8")` is `"p-8"`. That is a real understanding of
26
+ * Tailwind's shorthand groups, which is why it is `tailwind-merge` and not a
27
+ * `join(" ")`.
28
+ *
29
+ * Re-exported rather than reimplemented. The implementation is
30
+ * `tailwind-variants`' `cn`, which `@heroui/react` re-exports and which every
31
+ * HeroUI component uses internally through `tv` - so a partner's override is
32
+ * resolved by exactly the same rules whether it lands on one of these
33
+ * components or on a HeroUI one. It is reached through `@heroui/react` rather
34
+ * than `tailwind-variants` directly because HeroUI is already a declared peer
35
+ * of both UI entry points, and importing a package this manifest does not name
36
+ * would be relying on someone else's transitive tree.
37
+ *
38
+ * Safe in the server-rendered `./ui` entry: the `@heroui/react` barrel carries
39
+ * no top-level `"use client"` (only its individual components do), so `cn`
40
+ * arrives as a real function rather than a client-reference proxy. That
41
+ * distinction matters for a *function* in a way it does not for a component -
42
+ * `ui/section.tsx` has always imported `Card` from the same barrel.
43
+ *
44
+ * Not exported from the root entry, which is isomorphic and React-free. It is
45
+ * public from `./ui`, because a partner composing on top of these primitives
46
+ * needs the same merge the primitives use.
47
+ */
48
+ export { cn } from "@heroui/react";
@@ -157,4 +157,4 @@ export class ConnectClient {
157
157
  }
158
158
  }
159
159
  /** Kept in step with `package.json` by `sdk.test.ts`. */
160
- export const SDK_VERSION = "0.25.0";
160
+ export const SDK_VERSION = "0.26.0";
package/dist/esm/store.js CHANGED
@@ -97,6 +97,12 @@ const EQUALITY_OPERATORS = new Set(["==", "in"]);
97
97
  *
98
98
  * Throws for a query Firestore cannot serve at all - two `array-contains`
99
99
  * filters, ranges over two fields, or a range ordered by a different field.
100
+ *
101
+ * A **range bounded on both sides** over one field needs no declaration: two
102
+ * filters, one field, and the automatic single-field index serves it. Ask this
103
+ * function with the filters you actually send rather than with one of the two
104
+ * bounds - the answer is the same, and modelling the range as a single `>=`
105
+ * is how a schema comes to declare a shape its own runtime does not make.
100
106
  */
101
107
  export function storeIndexFor(query) {
102
108
  const filters = query.filters ?? [];
@@ -118,7 +124,14 @@ export function storeIndexFor(query) {
118
124
  }
119
125
  const ordered = orderBy?.field ?? rangeField;
120
126
  const distinct = new Set([...filters.map((filter) => filter.field), ...(ordered ? [ordered] : [])]);
121
- if (filters.length <= 1 && distinct.size <= 1)
127
+ // Distinct *fields*, not filter count. A two-sided range over one field -
128
+ // `day >= from AND day <= to ORDER BY day`, the shape of every date window -
129
+ // is exactly what an automatic single-field index is for. Counting filters
130
+ // instead answered `[day asc]`: an index Firestore will not build, since a
131
+ // composite needs two fields, and which is by definition the automatic one
132
+ // already serving the query. `array-contains` is the exception, because an
133
+ // array field's automatic index does not serve an inequality on that field.
134
+ if (distinct.size <= 1 && (arrayContains.length === 0 || filters.length <= 1))
122
135
  return null;
123
136
  const fields = [
124
137
  ...[...new Set(equalities.map((filter) => filter.field))]
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Card } from "@heroui/react";
3
+ import { cn } from "../cn.js";
3
4
  /**
4
5
  * What a list, grid or table shows when it has nothing to show.
5
6
  *
@@ -21,5 +22,5 @@ export function ConnectEmptyState({ icon, title, description, action, variant =
21
22
  const body = (_jsxs("div", { className: "flex flex-col items-center text-center", children: [icon && (_jsx("span", { "aria-hidden": true, className: "mb-3 text-muted [&>svg]:size-8", children: icon })), _jsx("p", { className: "text-sm font-medium text-foreground", children: title }), description && _jsx("p", { className: "mt-1 max-w-md text-sm text-muted", children: description }), action && _jsx("div", { className: "mt-3", children: action })] }));
22
23
  if (variant === "plain")
23
24
  return _jsx("div", { className: className, children: body });
24
- return (_jsx(Card, { className: `p-5 sm:p-8 ${variant === "dashed" ? "border-dashed" : ""} ${className ?? ""}`, children: body }));
25
+ return (_jsx(Card, { className: cn("p-5 sm:p-8", variant === "dashed" ? "border-dashed" : "", className), children: body }));
25
26
  }
@@ -32,3 +32,9 @@ export { ConnectSection } from "./section.js";
32
32
  export { ConnectEmptyState } from "./empty-state.js";
33
33
  export { ConnectSettingRow } from "./setting-row.js";
34
34
  export { ConnectStatusChip } from "./status-chip.js";
35
+ /**
36
+ * Merge class names the way these components do internally, so a utility you
37
+ * pass wins over a conflicting default instead of racing it in the stylesheet.
38
+ * Use it when composing your own components on top of these.
39
+ */
40
+ export { cn } from "../cn.js";
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from "../cn.js";
2
3
  /**
3
4
  * The title / description / action block a page opens with.
4
5
  *
@@ -20,5 +21,5 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
20
21
  * why it lives in `@cosmoops/connect-sdk/ui` rather than `/client`.
21
22
  */
22
23
  export function ConnectPageHeader({ title, description, action, clamp = true, className, }) {
23
- return (_jsxs("header", { className: `flex items-start justify-between gap-3 ${className ?? ""}`, children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h2", { className: "flex items-center gap-2 text-lg font-semibold", children: title }), description && (_jsx("p", { className: `mt-2 text-sm text-muted ${clamp ? "line-clamp-2 sm:line-clamp-none" : ""}`, children: description }))] }), action && _jsx("div", { className: "shrink-0", children: action })] }));
24
+ return (_jsxs("header", { className: cn("flex items-start justify-between gap-3", className), children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h2", { className: "flex items-center gap-2 text-lg font-semibold", children: title }), description && (_jsx("p", { className: `mt-2 text-sm text-muted ${clamp ? "line-clamp-2 sm:line-clamp-none" : ""}`, children: description }))] }), action && _jsx("div", { className: "shrink-0", children: action })] }));
24
25
  }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Suspense } from "react";
3
3
  import { ConnectPageHeader } from "./page-header.js";
4
+ import { cn } from "../cn.js";
4
5
  /**
5
6
  * The frame a page opens with: its vertical rhythm, its header, and the
6
7
  * suspended regions beneath.
@@ -20,8 +21,8 @@ import { ConnectPageHeader } from "./page-header.js";
20
21
  *
21
22
  * `space-y-5` is the page gap, owned here so it has exactly one definition.
22
23
  */
23
- export function ConnectPageShell({ title, description, action, clamp, above, children, }) {
24
- return (_jsxs("div", { className: "space-y-5", children: [above, title !== undefined && (_jsx(ConnectPageHeader, { title: title, description: description, action: action, clamp: clamp })), children] }));
24
+ export function ConnectPageShell({ title, description, action, clamp, above, children, className, }) {
25
+ return (_jsxs("div", { className: cn("space-y-5", className), children: [above, title !== undefined && (_jsx(ConnectPageHeader, { title: title, description: description, action: action, clamp: clamp })), children] }));
25
26
  }
26
27
  /**
27
28
  * The page header, for a detail route that can only name itself once its data
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Card } from "@heroui/react";
3
+ import { cn } from "../cn.js";
3
4
  /**
4
5
  * One titled block inside a page: a card, its heading, what it is for, and the
5
6
  * one control that acts on it.
@@ -13,5 +14,5 @@ import { Card } from "@heroui/react";
13
14
  * block, so its position does not move with the length of the description.
14
15
  */
15
16
  export function ConnectSection({ title, description, action, titleId, className, bodyClassName, children, }) {
16
- return (_jsxs(Card, { className: className ?? "p-4 sm:p-5", children: [_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-3", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h3", { id: titleId, className: "text-base font-semibold", children: title }), description && _jsx("p", { className: "mt-1 text-sm text-muted", children: description })] }), action && _jsx("div", { className: "shrink-0", children: action })] }), children && _jsx("div", { className: bodyClassName ?? "mt-4 space-y-4", children: children })] }));
17
+ return (_jsxs(Card, { className: cn("p-4 sm:p-5", className), children: [_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-3", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h3", { id: titleId, className: "text-base font-semibold", children: title }), description && _jsx("p", { className: "mt-1 text-sm text-muted", children: description })] }), action && _jsx("div", { className: "shrink-0", children: action })] }), children && _jsx("div", { className: bodyClassName ?? "mt-4 space-y-4", children: children })] }));
17
18
  }
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from "../cn.js";
2
3
  /**
3
4
  * One labelled setting inside a `ConnectSection`.
4
5
  *
@@ -7,5 +8,5 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
8
  * intrinsic width so truncation lands on the description.
8
9
  */
9
10
  export function ConnectSettingRow({ title, description, action, children, className, }) {
10
- return (_jsxs("div", { className: `flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between ${className ?? ""}`, children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "text-sm font-medium text-foreground", children: title }), description && _jsx("p", { className: "mt-1 text-sm text-muted", children: description }), children && _jsx("div", { className: "mt-3", children: children })] }), action && _jsx("div", { className: "shrink-0 sm:pl-4", children: action })] }));
11
+ return (_jsxs("div", { className: cn("flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", className), children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "text-sm font-medium text-foreground", children: title }), description && _jsx("p", { className: "mt-1 text-sm text-muted", children: description }), children && _jsx("div", { className: "mt-3", children: children })] }), action && _jsx("div", { className: "shrink-0 sm:pl-4", children: action })] }));
11
12
  }
package/dist/theme.css CHANGED
@@ -106,6 +106,34 @@
106
106
  --overlay: oklch(21% 0.002 145.67);
107
107
  }
108
108
 
109
+ /* ---------------------------------------------------------------------------
110
+ * Radius, as one number.
111
+ *
112
+ * HeroUI derives its whole radius scale from `--radius`: `rounded-sm` is
113
+ * `--radius * 0.5`, `rounded-lg` is `--radius * 1`, and `rounded-3xl` - what
114
+ * `.button` resolves to - is `--radius * 3`. The scale is a set of *ratios*,
115
+ * so this token is the only magnitude in it and one value decides every
116
+ * corner in every component.
117
+ *
118
+ * The stock pair is `--radius: 0.5rem` with `--field-radius` derived at
119
+ * `calc(var(--radius) * 1.5)`. Connect's own app has always run at `0.25rem`,
120
+ * but the shared theme carried neither token - so every partner platform
121
+ * silently inherited HeroUI's stock values and drew its cards and inputs at
122
+ * twice Connect's radius. That was the one place platforms sharing this
123
+ * palette visibly disagreed. Stating it here is what makes "the same theme"
124
+ * mean the same shapes as well as the same colours.
125
+ *
126
+ * `--field-radius` is set rather than derived, deliberately: at 1.5x an input
127
+ * rounds harder than the card holding it, which inverts the nesting the
128
+ * radius scale exists to express.
129
+ *
130
+ * Radius does not vary by theme, so this sits outside the light/dark blocks.
131
+ * ------------------------------------------------------------------------ */
132
+ :root {
133
+ --radius: 0.25rem;
134
+ --field-radius: 0.25rem;
135
+ }
136
+
109
137
  body {
110
138
  background: var(--background);
111
139
  color: var(--foreground);
@@ -197,3 +225,28 @@ body {
197
225
  animation: none;
198
226
  }
199
227
  }
228
+
229
+ /* ---------------------------------------------------------------------------
230
+ * A hand-rolled button still looks pressable.
231
+ *
232
+ * Tailwind v4's Preflight sets `cursor: default` on `button`, reversing v3 -
233
+ * and HeroUI restores the pointer only on its *own* classes, through
234
+ * `--cursor-interactive` on `.button`, `.chip` triggers and friends. So a
235
+ * plain `<button>` - which every app writes: a menu row, a filter chip, a
236
+ * calendar day, a card that is really a press target - renders with an arrow
237
+ * and reads as text.
238
+ *
239
+ * That is not a per-button oversight to fix per button. It is the default
240
+ * being wrong for this codebase, so it is corrected once, here, where every
241
+ * application platform importing this theme picks it up.
242
+ *
243
+ * `:where()` keeps the specificity at zero, so any component rule - including
244
+ * HeroUI's own disabled handling, which swaps in `--cursor-disabled` - still
245
+ * wins outright. The `:not()`s are belt and braces for the un-classed case,
246
+ * where there is no component rule to defer to.
247
+ * ------------------------------------------------------------------------ */
248
+ @layer base {
249
+ :where(button, [role="button"]):not(:disabled):not([aria-disabled="true"]):not([data-disabled="true"]) {
250
+ cursor: pointer;
251
+ }
252
+ }
@@ -56,7 +56,16 @@ export interface ConnectAccessGateProps {
56
56
  children: ReactNode;
57
57
  /** Replaces the default card for every hard-block state (`suspended`/`no_subscription`/`onboarding_pending`/`blocked`) uniformly. For a different card per state, call `useConnectAccessGate()` directly instead. */
58
58
  fallback?: ReactNode;
59
- /** Shown while the first read is in flight. */
59
+ /**
60
+ * Shown while the first read is in flight, **in place of** `children`.
61
+ *
62
+ * Opt-in, and rarely what you want. The default is to render `children`
63
+ * during that window - see the component's doc comment on why a blanket
64
+ * placeholder over a whole application is worse than the flash it avoids.
65
+ * Pass this only for a gate wrapped around one small region whose blocked
66
+ * and unblocked states differ enough that showing the unblocked one first
67
+ * would mislead.
68
+ */
60
69
  loading?: ReactNode;
61
70
  className?: string;
62
71
  }
@@ -85,9 +94,31 @@ export interface ConnectAccessGateProps {
85
94
  * entire app must not hide a public landing page behind an empty gate. Your
86
95
  * own sign-in flow decides what a signed-out visitor sees.
87
96
  *
97
+ * **While the verdict is unknown, `children` render.** The two reads behind
98
+ * this gate are client-side round trips that happen on *every* page load, and
99
+ * this component is mounted around an entire application - so blocking on
100
+ * them replaced every partner's whole page with one placeholder until both
101
+ * came back. Anything the host had streamed underneath was discarded: per-
102
+ * section skeletons never painted, and a page built as a static shell with
103
+ * suspended regions arrived all at once anyway. That is the common path, paid
104
+ * by every workspace on every navigation.
105
+ *
106
+ * Rendering `children` instead makes the loading case agree with the two
107
+ * cases either side of it. A *failed* read already falls through to `clear`
108
+ * ("fail open rather than hard-block the whole app on a network hiccup", in
109
+ * `useConnectAccessGate`), and a signed-out visitor already renders
110
+ * `children` too - "we do not know yet" was the one unknown treated as a
111
+ * refusal.
112
+ *
113
+ * What it costs is honest and small: a genuinely blocked workspace sees its
114
+ * app for the length of one round trip before the block card replaces it.
115
+ * That is the rare path, it is self-correcting, and it is the same exposure
116
+ * the failed-read branch has always accepted. Pass `loading` to opt back into
117
+ * a placeholder for a gate around one small region.
118
+ *
88
119
  * **This is a UX gate, not a security boundary** - same caveat as every
89
- * other gate in this package. The authoritative check is
90
- * `client.entitlements.getReadiness` on your server, before you do the
91
- * thing.
120
+ * other gate in this package, and the reason the trade above is available at
121
+ * all. The authoritative check is `client.entitlements.getReadiness` on your
122
+ * server, before you do the thing.
92
123
  */
93
124
  export declare function ConnectAccessGate({ children, fallback, loading, className }: ConnectAccessGateProps): import("react").JSX.Element;
@@ -8,6 +8,11 @@ export interface ConnectCursorPaginationProps {
8
8
  onNext: (cursor: string) => void;
9
9
  onPrevious: () => void;
10
10
  isPending?: boolean;
11
+ /**
12
+ * Merged onto the root, so a utility here replaces the conflicting default
13
+ * rather than sitting beside it. See `cn`.
14
+ */
15
+ className?: string;
11
16
  /** Singular/plural noun for the count, e.g. `["contact", "contacts"]`. */
12
17
  noun?: readonly [string, string];
13
18
  }
@@ -24,4 +29,4 @@ export interface ConnectCursorPaginationProps {
24
29
  * filtering or paging changed the result set. Without it the table silently
25
30
  * swaps its contents.
26
31
  */
27
- export declare function ConnectCursorPagination({ shown, nextCursor, hasPrevious, onNext, onPrevious, isPending, noun, }: Readonly<ConnectCursorPaginationProps>): import("react").JSX.Element;
32
+ export declare function ConnectCursorPagination({ shown, nextCursor, hasPrevious, onNext, onPrevious, isPending, noun, className, }: Readonly<ConnectCursorPaginationProps>): import("react").JSX.Element;
@@ -8,6 +8,11 @@ export interface ConnectFilterTabsProps<TValue extends string> {
8
8
  options: ReadonlyArray<ConnectFilterTabsOption<TValue>>;
9
9
  value: TValue | null;
10
10
  onChange: (value: TValue | null) => void;
11
+ /**
12
+ * Merged onto the root, so a utility here replaces the conflicting default
13
+ * rather than sitting beside it. See `cn`.
14
+ */
15
+ className?: string;
11
16
  /** Names the group for assistive technology - "Filter contacts by lifecycle". */
12
17
  label: string;
13
18
  }
@@ -20,7 +25,8 @@ export interface ConnectFilterTabsProps<TValue extends string> {
20
25
  * selected one should be announced as selected. A group of `<button>`s gets
21
26
  * neither, and that is the usual hand-rolled shape.
22
27
  *
23
- * Scrolls horizontally rather than wrapping on a narrow viewport - a filter
24
- * strip that wraps to three lines pushes the list itself off the screen.
28
+ * Wraps rather than scrolling on a narrow viewport. See the note on the
29
+ * container below: a horizontal scroller nested in a vertically scrolling
30
+ * page hides its overflow rather than deferring it.
25
31
  */
26
- export declare function ConnectFilterTabs<TValue extends string>({ options, value, onChange, label, }: Readonly<ConnectFilterTabsProps<TValue>>): import("react").JSX.Element;
32
+ export declare function ConnectFilterTabs<TValue extends string>({ options, value, onChange, label, className, }: Readonly<ConnectFilterTabsProps<TValue>>): import("react").JSX.Element;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Merge class names so a caller's Tailwind utility actually wins.
3
+ *
4
+ * Every component in this package carries default utilities, and a partner
5
+ * styling one reaches for `className`. Concatenating the two does not do what
6
+ * it looks like it does:
7
+ *
8
+ * ```tsx
9
+ * // ConnectBookingCalendar with className="gap-8"
10
+ * <div className={`flex flex-col gap-4 ${className}`} />
11
+ * // -> class="flex flex-col gap-4 gap-8"
12
+ * ```
13
+ *
14
+ * Both rules are now in the stylesheet and both match, so the winner is
15
+ * decided by **source order in the generated CSS**, not by the order they
16
+ * appear in the attribute. Tailwind sorts its output by its own internal
17
+ * ordering, so whether `gap-8` beats `gap-4` depends on which utility Tailwind
18
+ * happens to emit later - `p-8` over `px-3 py-2` lands one way, `gap-8` over
19
+ * `gap-4` the other. An override that works on one prop and silently does
20
+ * nothing on the next is worse than one that never works, because nobody
21
+ * suspects the mechanism.
22
+ *
23
+ * `cn` resolves it by *removing* the conflicting default rather than adding to
24
+ * it - `cn("flex flex-col gap-4", "gap-8")` is `"flex flex-col gap-8"`, and
25
+ * `cn("px-3 py-2", "p-8")` is `"p-8"`. That is a real understanding of
26
+ * Tailwind's shorthand groups, which is why it is `tailwind-merge` and not a
27
+ * `join(" ")`.
28
+ *
29
+ * Re-exported rather than reimplemented. The implementation is
30
+ * `tailwind-variants`' `cn`, which `@heroui/react` re-exports and which every
31
+ * HeroUI component uses internally through `tv` - so a partner's override is
32
+ * resolved by exactly the same rules whether it lands on one of these
33
+ * components or on a HeroUI one. It is reached through `@heroui/react` rather
34
+ * than `tailwind-variants` directly because HeroUI is already a declared peer
35
+ * of both UI entry points, and importing a package this manifest does not name
36
+ * would be relying on someone else's transitive tree.
37
+ *
38
+ * Safe in the server-rendered `./ui` entry: the `@heroui/react` barrel carries
39
+ * no top-level `"use client"` (only its individual components do), so `cn`
40
+ * arrives as a real function rather than a client-reference proxy. That
41
+ * distinction matters for a *function* in a way it does not for a component -
42
+ * `ui/section.tsx` has always imported `Card` from the same barrel.
43
+ *
44
+ * Not exported from the root entry, which is isomorphic and React-free. It is
45
+ * public from `./ui`, because a partner composing on top of these primitives
46
+ * needs the same merge the primitives use.
47
+ */
48
+ export { cn } from "@heroui/react";
@@ -123,4 +123,4 @@ export declare class ConnectClient {
123
123
  constructor(options: ConnectClientOptions);
124
124
  }
125
125
  /** Kept in step with `package.json` by `sdk.test.ts`. */
126
- export declare const SDK_VERSION = "0.25.0";
126
+ export declare const SDK_VERSION = "0.26.0";
@@ -82,6 +82,12 @@ export interface StoreIndexField {
82
82
  *
83
83
  * Throws for a query Firestore cannot serve at all - two `array-contains`
84
84
  * filters, ranges over two fields, or a range ordered by a different field.
85
+ *
86
+ * A **range bounded on both sides** over one field needs no declaration: two
87
+ * filters, one field, and the automatic single-field index serves it. Ask this
88
+ * function with the filters you actually send rather than with one of the two
89
+ * bounds - the answer is the same, and modelling the range as a single `>=`
90
+ * is how a schema comes to declare a shape its own runtime does not make.
85
91
  */
86
92
  export declare function storeIndexFor(query: {
87
93
  filters?: readonly StoreQueryFilter[];
@@ -38,3 +38,9 @@ export { ConnectSettingRow } from "./setting-row.js";
38
38
  export type { ConnectSettingRowProps } from "./setting-row.js";
39
39
  export { ConnectStatusChip } from "./status-chip.js";
40
40
  export type { ConnectStatusTone, ConnectStatusPresentation } from "./status-chip.js";
41
+ /**
42
+ * Merge class names the way these components do internally, so a utility you
43
+ * pass wins over a conflicting default instead of racing it in the stylesheet.
44
+ * Use it when composing your own components on top of these.
45
+ */
46
+ export { cn } from "../cn.js";
@@ -7,6 +7,12 @@ export interface ConnectPageShellProps {
7
7
  clamp?: boolean;
8
8
  /** Rendered above the header - a breadcrumb trail, a back link, a banner. */
9
9
  above?: ReactNode;
10
+ /**
11
+ * Merged onto the page root, so a utility here beats the default rather than
12
+ * sitting beside it - `className="space-y-8"` really does widen the page gap.
13
+ * See `cn`.
14
+ */
15
+ className?: string;
10
16
  children?: ReactNode;
11
17
  }
12
18
  /**
@@ -28,7 +34,7 @@ export interface ConnectPageShellProps {
28
34
  *
29
35
  * `space-y-5` is the page gap, owned here so it has exactly one definition.
30
36
  */
31
- export declare function ConnectPageShell({ title, description, action, clamp, above, children, }: Readonly<ConnectPageShellProps>): import("react").JSX.Element;
37
+ export declare function ConnectPageShell({ title, description, action, clamp, above, children, className, }: Readonly<ConnectPageShellProps>): import("react").JSX.Element;
32
38
  export declare namespace ConnectPageShell {
33
39
  var Header: typeof ConnectPageHeader;
34
40
  var Async: ({ fallback, children, }: Readonly<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmoops/connect-sdk",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Official server-side SDK for the CosmoOps Connect platform API - Login with Connect, entitlements, billing and metering.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "commonjs",