@meetreeve/ui 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -584,4 +584,71 @@ interface ContextSwitcherProps {
584
584
  }
585
585
  declare function ContextSwitcher({ items, activeRefId, onSelect, footer, placeholder, collapsed, side, align, contentClassName, }: ContextSwitcherProps): react.JSX.Element;
586
586
 
587
- export { type AppRungItem, AppSidebar, type AppSidebarBrand, type AppSidebarModuleDef, type AppSidebarProps, type AppSidebarTopItem, type Brand, BrandGlyph, type BrandGlyphProps, type ContextOrgGroup, ContextSwitcher, type ContextSwitcherItem, type ContextSwitcherProps, type FallbackModuleDef, Flyout, type FlyoutChild, type FlyoutProps, GLYPH_CANVAS_FILL, type GroupRungItem, type HeaderNavItem, LOCKUP_GAP, LOCKUP_ROW, MARK_CAP_RATIO, MARK_WEIGHT, REEVE_ACCENT, ResponsiveHeader, type ResponsiveHeaderProps, type RungItem, type SidebarLadder, type SidebarLadderParsed, type SidebarRung, type UseLadderRungOptions, WORDMARK_WEIGHT, deriveFallbackLadder, faviconUrl, groupByOrg, reconcileLadder, resolveLadder, useLadderRung, useResponsivePlaceholder, zSidebarLadder };
587
+ /**
588
+ * ConnectorPicker (DEV-5578 / CONN-3) — the one data-driven connect
589
+ * component in @meetreeve/ui. Renders a catalog of connectable entries from
590
+ * an injected client, owning the whole per-entry state machine
591
+ * (not-connected -> connecting -> connected, disconnect, inline per-tile
592
+ * errors, and a return-from-OAuth refresh). Every label is derived from the
593
+ * entry data the client returns — this file must never hardcode a specific
594
+ * provider's name (an explicit acceptance criterion); a host app supplies
595
+ * that entirely through `ConnectableEntry.name` / `.logo` / `.category`.
596
+ *
597
+ * Transport-injection precedent: `linkComponent` (DEV-4429/DEV-4391) —
598
+ * inject the transport, keep behavior in the package. Here the "transport"
599
+ * is the whole backend round-trip (list/connect/disconnect), injected as
600
+ * `client` (Matt's decision, 2026-07-25): Reeve passes one built on its own
601
+ * fetch client; a tenant clone passes ~10 lines of its own.
602
+ */
603
+ type ConnectableTransport = "composio" | "native";
604
+ type ConnectableCapability = "read" | "write" | "readwrite";
605
+ interface ConnectableEntry {
606
+ slug: string;
607
+ name: string;
608
+ logo: string | null;
609
+ category: string | null;
610
+ transport: ConnectableTransport;
611
+ capability: ConnectableCapability;
612
+ purposes: string[];
613
+ connection: {
614
+ status: "connected" | "not_connected";
615
+ connected_account_id: string | null;
616
+ since: string | null;
617
+ };
618
+ /** true = tenant has a live connection but the entry left the allowlist. */
619
+ deprecated: boolean;
620
+ }
621
+ interface ConnectorPickerClient {
622
+ listConnectable(opts: {
623
+ purpose?: string;
624
+ }): Promise<ConnectableEntry[]>;
625
+ /** composio -> open the returned connect_url; native may resolve with none. */
626
+ connect(entry: ConnectableEntry): Promise<{
627
+ connect_url?: string;
628
+ }>;
629
+ disconnect(entry: ConnectableEntry): Promise<void>;
630
+ }
631
+ /**
632
+ * Structurally identical to the app's own `DnaLens` (src/lib/dna-lens.ts) —
633
+ * this package must never import app code, so it declares its own copy
634
+ * rather than sharing the type.
635
+ */
636
+ type ConnectorPickerLens = "visitor" | "member" | "owner";
637
+ interface ConnectorPickerProps {
638
+ /** The injected transport adapter — required. */
639
+ client: ConnectorPickerClient;
640
+ /** Optional purpose slice, forwarded to `client.listConnectable`. */
641
+ purpose?: string;
642
+ /**
643
+ * Default "owner". "visitor"/"member" render nothing and make ZERO
644
+ * client calls — some host surfaces (e.g. a structurally owner-only
645
+ * knowledge page) have no visitor/member concept at all, and forcing them
646
+ * to synthesize a lens would import dead branches.
647
+ */
648
+ lens?: ConnectorPickerLens;
649
+ layout?: "tiles" | "list";
650
+ onConnected?: (entry: ConnectableEntry) => void;
651
+ }
652
+ declare function ConnectorPicker({ client, purpose, lens, layout, onConnected, }: ConnectorPickerProps): react.JSX.Element | null;
653
+
654
+ export { type AppRungItem, AppSidebar, type AppSidebarBrand, type AppSidebarModuleDef, type AppSidebarProps, type AppSidebarTopItem, type Brand, BrandGlyph, type BrandGlyphProps, type ConnectableCapability, type ConnectableEntry, type ConnectableTransport, ConnectorPicker, type ConnectorPickerClient, type ConnectorPickerLens, type ConnectorPickerProps, type ContextOrgGroup, ContextSwitcher, type ContextSwitcherItem, type ContextSwitcherProps, type FallbackModuleDef, Flyout, type FlyoutChild, type FlyoutProps, GLYPH_CANVAS_FILL, type GroupRungItem, type HeaderNavItem, LOCKUP_GAP, LOCKUP_ROW, MARK_CAP_RATIO, MARK_WEIGHT, REEVE_ACCENT, ResponsiveHeader, type ResponsiveHeaderProps, type RungItem, type SidebarLadder, type SidebarLadderParsed, type SidebarRung, type UseLadderRungOptions, WORDMARK_WEIGHT, deriveFallbackLadder, faviconUrl, groupByOrg, reconcileLadder, resolveLadder, useLadderRung, useResponsivePlaceholder, zSidebarLadder };
package/dist/index.d.ts CHANGED
@@ -584,4 +584,71 @@ interface ContextSwitcherProps {
584
584
  }
585
585
  declare function ContextSwitcher({ items, activeRefId, onSelect, footer, placeholder, collapsed, side, align, contentClassName, }: ContextSwitcherProps): react.JSX.Element;
586
586
 
587
- export { type AppRungItem, AppSidebar, type AppSidebarBrand, type AppSidebarModuleDef, type AppSidebarProps, type AppSidebarTopItem, type Brand, BrandGlyph, type BrandGlyphProps, type ContextOrgGroup, ContextSwitcher, type ContextSwitcherItem, type ContextSwitcherProps, type FallbackModuleDef, Flyout, type FlyoutChild, type FlyoutProps, GLYPH_CANVAS_FILL, type GroupRungItem, type HeaderNavItem, LOCKUP_GAP, LOCKUP_ROW, MARK_CAP_RATIO, MARK_WEIGHT, REEVE_ACCENT, ResponsiveHeader, type ResponsiveHeaderProps, type RungItem, type SidebarLadder, type SidebarLadderParsed, type SidebarRung, type UseLadderRungOptions, WORDMARK_WEIGHT, deriveFallbackLadder, faviconUrl, groupByOrg, reconcileLadder, resolveLadder, useLadderRung, useResponsivePlaceholder, zSidebarLadder };
587
+ /**
588
+ * ConnectorPicker (DEV-5578 / CONN-3) — the one data-driven connect
589
+ * component in @meetreeve/ui. Renders a catalog of connectable entries from
590
+ * an injected client, owning the whole per-entry state machine
591
+ * (not-connected -> connecting -> connected, disconnect, inline per-tile
592
+ * errors, and a return-from-OAuth refresh). Every label is derived from the
593
+ * entry data the client returns — this file must never hardcode a specific
594
+ * provider's name (an explicit acceptance criterion); a host app supplies
595
+ * that entirely through `ConnectableEntry.name` / `.logo` / `.category`.
596
+ *
597
+ * Transport-injection precedent: `linkComponent` (DEV-4429/DEV-4391) —
598
+ * inject the transport, keep behavior in the package. Here the "transport"
599
+ * is the whole backend round-trip (list/connect/disconnect), injected as
600
+ * `client` (Matt's decision, 2026-07-25): Reeve passes one built on its own
601
+ * fetch client; a tenant clone passes ~10 lines of its own.
602
+ */
603
+ type ConnectableTransport = "composio" | "native";
604
+ type ConnectableCapability = "read" | "write" | "readwrite";
605
+ interface ConnectableEntry {
606
+ slug: string;
607
+ name: string;
608
+ logo: string | null;
609
+ category: string | null;
610
+ transport: ConnectableTransport;
611
+ capability: ConnectableCapability;
612
+ purposes: string[];
613
+ connection: {
614
+ status: "connected" | "not_connected";
615
+ connected_account_id: string | null;
616
+ since: string | null;
617
+ };
618
+ /** true = tenant has a live connection but the entry left the allowlist. */
619
+ deprecated: boolean;
620
+ }
621
+ interface ConnectorPickerClient {
622
+ listConnectable(opts: {
623
+ purpose?: string;
624
+ }): Promise<ConnectableEntry[]>;
625
+ /** composio -> open the returned connect_url; native may resolve with none. */
626
+ connect(entry: ConnectableEntry): Promise<{
627
+ connect_url?: string;
628
+ }>;
629
+ disconnect(entry: ConnectableEntry): Promise<void>;
630
+ }
631
+ /**
632
+ * Structurally identical to the app's own `DnaLens` (src/lib/dna-lens.ts) —
633
+ * this package must never import app code, so it declares its own copy
634
+ * rather than sharing the type.
635
+ */
636
+ type ConnectorPickerLens = "visitor" | "member" | "owner";
637
+ interface ConnectorPickerProps {
638
+ /** The injected transport adapter — required. */
639
+ client: ConnectorPickerClient;
640
+ /** Optional purpose slice, forwarded to `client.listConnectable`. */
641
+ purpose?: string;
642
+ /**
643
+ * Default "owner". "visitor"/"member" render nothing and make ZERO
644
+ * client calls — some host surfaces (e.g. a structurally owner-only
645
+ * knowledge page) have no visitor/member concept at all, and forcing them
646
+ * to synthesize a lens would import dead branches.
647
+ */
648
+ lens?: ConnectorPickerLens;
649
+ layout?: "tiles" | "list";
650
+ onConnected?: (entry: ConnectableEntry) => void;
651
+ }
652
+ declare function ConnectorPicker({ client, purpose, lens, layout, onConnected, }: ConnectorPickerProps): react.JSX.Element | null;
653
+
654
+ export { type AppRungItem, AppSidebar, type AppSidebarBrand, type AppSidebarModuleDef, type AppSidebarProps, type AppSidebarTopItem, type Brand, BrandGlyph, type BrandGlyphProps, type ConnectableCapability, type ConnectableEntry, type ConnectableTransport, ConnectorPicker, type ConnectorPickerClient, type ConnectorPickerLens, type ConnectorPickerProps, type ContextOrgGroup, ContextSwitcher, type ContextSwitcherItem, type ContextSwitcherProps, type FallbackModuleDef, Flyout, type FlyoutChild, type FlyoutProps, GLYPH_CANVAS_FILL, type GroupRungItem, type HeaderNavItem, LOCKUP_GAP, LOCKUP_ROW, MARK_CAP_RATIO, MARK_WEIGHT, REEVE_ACCENT, ResponsiveHeader, type ResponsiveHeaderProps, type RungItem, type SidebarLadder, type SidebarLadderParsed, type SidebarRung, type UseLadderRungOptions, WORDMARK_WEIGHT, deriveFallbackLadder, faviconUrl, groupByOrg, reconcileLadder, resolveLadder, useLadderRung, useResponsivePlaceholder, zSidebarLadder };
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@ var src_exports = {};
33
33
  __export(src_exports, {
34
34
  AppSidebar: () => AppSidebar,
35
35
  BrandGlyph: () => BrandGlyph,
36
+ ConnectorPicker: () => ConnectorPicker,
36
37
  ContextSwitcher: () => ContextSwitcher,
37
38
  Flyout: () => Flyout,
38
39
  GLYPH_CANVAS_FILL: () => GLYPH_CANVAS_FILL,
@@ -838,13 +839,23 @@ function Flyout({
838
839
  className: cn(
839
840
  "group flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-sm transition-colors",
840
841
  collapsed && "justify-center px-0",
841
- active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
842
+ // DEV-6586: this trigger renders as a row inside AppSidebar's own
843
+ // `<nav>` (a `bg-sidebar` surface that stays dark navy in both
844
+ // themes) — same fix as NavLink, same measured ratios (default
845
+ // 8.05:1, hover 13.65:1, active 10.95:1). The flyout PANEL below
846
+ // is intentionally left on document tokens: it renders via
847
+ // `position: fixed`, floating over the main content area on
848
+ // `bg-background`, not over the sidebar.
849
+ active ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-sidebar-foreground/70 hover:bg-sidebar-foreground/5 hover:text-sidebar-foreground"
842
850
  ),
843
851
  children: [
844
852
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
845
853
  Icon,
846
854
  {
847
- className: cn("size-4 shrink-0", active ? "text-primary" : "text-muted-foreground"),
855
+ className: cn(
856
+ "size-4 shrink-0",
857
+ active ? "text-sidebar-accent-foreground" : "text-sidebar-foreground/70"
858
+ ),
848
859
  strokeWidth: 1.5,
849
860
  "aria-hidden": "true"
850
861
  }
@@ -927,13 +938,23 @@ function NavLink({
927
938
  className: cn(
928
939
  "group flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm transition-colors",
929
940
  collapsed && "justify-center px-0",
930
- active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
941
+ // DEV-6586: the sidebar is a permanently dark-navy surface in BOTH
942
+ // app themes (see `--sidebar-*` in globals.css) — styling from
943
+ // document-theme tokens (text-foreground/text-muted-foreground) put
944
+ // dark-on-dark text here in light mode. `--sidebar-foreground` /
945
+ // `--sidebar-accent(-foreground)` are theme-invariant, so hover/
946
+ // active reliably INCREASE legibility instead of erasing it:
947
+ // default 8.05:1, hover 13.65:1, active 10.95:1 (all >= 4.5:1 AA).
948
+ active ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-sidebar-foreground/70 hover:bg-sidebar-foreground/5 hover:text-sidebar-foreground"
931
949
  ),
932
950
  children: [
933
951
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
934
952
  Icon,
935
953
  {
936
- className: cn("size-4 shrink-0", active ? "text-primary" : "text-muted-foreground"),
954
+ className: cn(
955
+ "size-4 shrink-0",
956
+ active ? "text-sidebar-accent-foreground" : "text-sidebar-foreground/70"
957
+ ),
937
958
  strokeWidth: 1.5,
938
959
  "aria-hidden": "true"
939
960
  }
@@ -1058,11 +1079,11 @@ function AppSidebar({
1058
1079
  label: item.label,
1059
1080
  icon: item.icon,
1060
1081
  active,
1061
- children,
1062
1082
  linkComponent,
1063
1083
  collapsed: collapsedRow,
1064
1084
  forceClosed: openFlyoutId !== null && openFlyoutId !== item.id,
1065
- onOpenChange: (isOpen) => handleFlyoutOpenChange(item.id, isOpen)
1085
+ onOpenChange: (isOpen) => handleFlyoutOpenChange(item.id, isOpen),
1086
+ children
1066
1087
  },
1067
1088
  item.id
1068
1089
  );
@@ -1076,7 +1097,7 @@ function AppSidebar({
1076
1097
  href: brand.href,
1077
1098
  "data-testid": "app-sidebar-brand",
1078
1099
  title: brand.label,
1079
- className: "flex items-center justify-center border-b border-border px-1 py-4",
1100
+ className: "flex items-center justify-center border-b border-sidebar-border px-1 py-4",
1080
1101
  children: [
1081
1102
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "sr-only", children: brand.label }),
1082
1103
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(BrandGlyph, { brand: { name: brand.label }, size: "xl" })
@@ -1089,9 +1110,9 @@ function AppSidebar({
1089
1110
  {
1090
1111
  href: brand.href,
1091
1112
  "data-testid": "app-sidebar-brand",
1092
- className: "flex items-center gap-2.5 border-b border-border px-5 py-4",
1113
+ className: "flex items-center gap-2.5 border-b border-sidebar-border px-5 py-4 text-sidebar-foreground",
1093
1114
  children: [
1094
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "grid size-7 place-items-center rounded-md bg-primary text-primary-foreground", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "font-mono text-sm font-bold", children: (brand.label[0] ?? "r").toUpperCase() }) }),
1115
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "grid size-7 place-items-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "font-mono text-sm font-bold", children: (brand.label[0] ?? "r").toUpperCase() }) }),
1095
1116
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "truncate text-sm font-semibold tracking-tight", children: brand.label })
1096
1117
  ]
1097
1118
  }
@@ -1147,7 +1168,7 @@ function AppSidebar({
1147
1168
  "aria-modal": "true",
1148
1169
  "aria-label": "Navigation",
1149
1170
  "data-testid": "app-sidebar-mobile-drawer",
1150
- className: "absolute inset-y-0 left-0 flex w-72 flex-col overflow-y-auto border-r border-border bg-sidebar",
1171
+ className: "absolute inset-y-0 left-0 flex w-72 flex-col overflow-y-auto border-r border-sidebar-border bg-sidebar",
1151
1172
  children: [
1152
1173
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "sticky top-0 z-10 flex justify-end bg-sidebar p-2", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1153
1174
  "button",
@@ -1158,7 +1179,7 @@ function AppSidebar({
1158
1179
  "aria-label": `Close ${menuLabel.toLowerCase()}`,
1159
1180
  onClick: () => setMobileOpen(false),
1160
1181
  style: TOUCH_TARGET_STYLE,
1161
- className: "flex items-center justify-center rounded-md p-2 text-foreground hover:bg-foreground/5",
1182
+ className: "flex items-center justify-center rounded-md p-2 text-sidebar-foreground/70 hover:bg-sidebar-foreground/5 hover:text-sidebar-foreground",
1162
1183
  children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_lucide_react2.X, { className: "h-5 w-5", "aria-hidden": "true" })
1163
1184
  }
1164
1185
  ) }),
@@ -1176,7 +1197,7 @@ function AppSidebar({
1176
1197
  {
1177
1198
  "data-testid": "app-sidebar-desktop",
1178
1199
  className: cn(
1179
- "hidden shrink-0 flex-col border-r border-border bg-sidebar md:flex",
1200
+ "hidden shrink-0 flex-col border-r border-sidebar-border bg-sidebar md:flex",
1180
1201
  collapsed ? COLLAPSED_WIDTH_CLASS : EXPANDED_WIDTH_CLASS,
1181
1202
  // DEV-5331: a plain CSS width transition is enough — no new deps
1182
1203
  // for a spring/JS animation. Respect prefers-reduced-motion.
@@ -1197,7 +1218,7 @@ function AppSidebar({
1197
1218
  ) })
1198
1219
  }
1199
1220
  ),
1200
- collapsible && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "border-t border-border px-2 py-2", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
1221
+ collapsible && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "border-t border-sidebar-border px-2 py-2", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
1201
1222
  "button",
1202
1223
  {
1203
1224
  type: "button",
@@ -1207,7 +1228,7 @@ function AppSidebar({
1207
1228
  title: collapsed ? "Expand sidebar" : "Collapse sidebar",
1208
1229
  onClick: handleToggleCollapsed,
1209
1230
  className: cn(
1210
- "flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground",
1231
+ "flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-foreground/5 hover:text-sidebar-foreground",
1211
1232
  collapsed && "justify-center px-0"
1212
1233
  ),
1213
1234
  children: [
@@ -1345,18 +1366,18 @@ function ContextSwitcher({
1345
1366
  type: "button",
1346
1367
  title: active?.name || placeholder,
1347
1368
  "aria-label": active?.name || placeholder,
1348
- className: "flex h-10 w-10 items-center justify-center rounded-lg text-zinc-700 transition-colors hover:bg-zinc-900/5 dark:text-zinc-300 dark:hover:bg-white/5",
1369
+ className: "flex h-10 w-10 items-center justify-center rounded-lg text-sidebar-foreground/70 transition-colors hover:bg-sidebar-foreground/5 hover:text-sidebar-foreground",
1349
1370
  children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(BrandGlyph, { brand: toBrand(active ?? { name: "" }), size: "lg" })
1350
1371
  }
1351
1372
  ) : /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
1352
1373
  "button",
1353
1374
  {
1354
1375
  type: "button",
1355
- className: "group flex items-center gap-2 rounded-md border border-border bg-card/60 px-2.5 py-1.5 text-sm font-medium transition-colors hover:bg-muted",
1376
+ className: "group flex items-center gap-2 rounded-md border border-sidebar-border bg-sidebar-accent/60 px-2.5 py-1.5 text-sm font-medium transition-colors hover:bg-sidebar",
1356
1377
  children: [
1357
1378
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(BrandGlyph, { brand: toBrand(active ?? { name: "" }) }),
1358
- /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "max-w-[140px] truncate text-foreground/90", children: active?.name || placeholder }),
1359
- /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_lucide_react3.ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" })
1379
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "max-w-[140px] truncate text-sidebar-accent-foreground", children: active?.name || placeholder }),
1380
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_lucide_react3.ChevronDown, { className: "h-3.5 w-3.5 text-sidebar-foreground/70 transition-transform group-data-[state=open]:rotate-180" })
1360
1381
  ]
1361
1382
  }
1362
1383
  ) }),
@@ -1395,10 +1416,263 @@ function ContextSwitcher({
1395
1416
  )
1396
1417
  ] });
1397
1418
  }
1419
+
1420
+ // src/connector-picker.tsx
1421
+ var import_react9 = require("react");
1422
+ var import_jsx_runtime7 = require("react/jsx-runtime");
1423
+ var DEFAULT_CONNECT_ERROR = "Couldn't start the connection. Please try again.";
1424
+ var DEFAULT_DISCONNECT_ERROR = "Couldn't disconnect. Please try again.";
1425
+ var DEFAULT_LOAD_ERROR = "Couldn't load connections. Please try again.";
1426
+ var SKELETON_COUNT = 3;
1427
+ function capabilityLabel(capability) {
1428
+ if (capability === "read") return "Read only";
1429
+ if (capability === "write") return "Write only";
1430
+ return "Read & write";
1431
+ }
1432
+ function formatSince(since) {
1433
+ const parsed = new Date(since);
1434
+ if (Number.isNaN(parsed.getTime())) return since;
1435
+ return parsed.toLocaleDateString(void 0, { year: "numeric", month: "short", day: "numeric" });
1436
+ }
1437
+ function errorMessageFrom(err, fallback) {
1438
+ return err instanceof Error && err.message ? err.message : fallback;
1439
+ }
1440
+ function ConnectorPicker({
1441
+ client,
1442
+ purpose,
1443
+ lens = "owner",
1444
+ layout = "tiles",
1445
+ onConnected
1446
+ }) {
1447
+ const gated = lens !== "owner";
1448
+ const [entries, setEntries] = (0, import_react9.useState)(null);
1449
+ const [loadError, setLoadError] = (0, import_react9.useState)(null);
1450
+ const [uiState, setUiState] = (0, import_react9.useState)({});
1451
+ const clientRef = (0, import_react9.useRef)(client);
1452
+ clientRef.current = client;
1453
+ const purposeRef = (0, import_react9.useRef)(purpose);
1454
+ purposeRef.current = purpose;
1455
+ const onConnectedRef = (0, import_react9.useRef)(onConnected);
1456
+ onConnectedRef.current = onConnected;
1457
+ const uiStateRef = (0, import_react9.useRef)(uiState);
1458
+ uiStateRef.current = uiState;
1459
+ const load = (0, import_react9.useCallback)(async () => {
1460
+ if (gated) return null;
1461
+ try {
1462
+ const list = await clientRef.current.listConnectable({ purpose: purposeRef.current });
1463
+ setEntries(list);
1464
+ setLoadError(null);
1465
+ return list;
1466
+ } catch (err) {
1467
+ setLoadError(errorMessageFrom(err, DEFAULT_LOAD_ERROR));
1468
+ return null;
1469
+ }
1470
+ }, [gated]);
1471
+ (0, import_react9.useEffect)(() => {
1472
+ if (gated) return;
1473
+ void load();
1474
+ }, [gated, load, purpose]);
1475
+ const refreshAfterReturn = (0, import_react9.useCallback)(async () => {
1476
+ const list = await load();
1477
+ if (!list) return;
1478
+ setUiState((prev) => {
1479
+ let changed = false;
1480
+ const next = { ...prev };
1481
+ for (const entry of list) {
1482
+ if (next[entry.slug]?.kind === "connecting" && entry.connection.status === "connected") {
1483
+ changed = true;
1484
+ delete next[entry.slug];
1485
+ onConnectedRef.current?.(entry);
1486
+ }
1487
+ }
1488
+ return changed ? next : prev;
1489
+ });
1490
+ }, [load]);
1491
+ (0, import_react9.useEffect)(() => {
1492
+ if (gated) return;
1493
+ function handleReturn() {
1494
+ if (document.visibilityState === "hidden") return;
1495
+ const hasPendingConnect = Object.values(uiStateRef.current).some(
1496
+ (s) => s.kind === "connecting"
1497
+ );
1498
+ if (!hasPendingConnect) return;
1499
+ void refreshAfterReturn();
1500
+ }
1501
+ window.addEventListener("focus", handleReturn);
1502
+ document.addEventListener("visibilitychange", handleReturn);
1503
+ return () => {
1504
+ window.removeEventListener("focus", handleReturn);
1505
+ document.removeEventListener("visibilitychange", handleReturn);
1506
+ };
1507
+ }, [gated, refreshAfterReturn]);
1508
+ const handleConnect = (0, import_react9.useCallback)(
1509
+ async (entry) => {
1510
+ setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "connecting" } }));
1511
+ try {
1512
+ const result = await clientRef.current.connect(entry);
1513
+ if (result?.connect_url) {
1514
+ window.open(result.connect_url, "_blank", "noopener,noreferrer");
1515
+ return;
1516
+ }
1517
+ const list = await load();
1518
+ const updated = list?.find((e) => e.slug === entry.slug) ?? null;
1519
+ setUiState((prev) => {
1520
+ const next = { ...prev };
1521
+ delete next[entry.slug];
1522
+ return next;
1523
+ });
1524
+ if (updated?.connection.status === "connected") {
1525
+ onConnectedRef.current?.(updated);
1526
+ }
1527
+ } catch (err) {
1528
+ const message = errorMessageFrom(err, DEFAULT_CONNECT_ERROR);
1529
+ setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "error", message } }));
1530
+ }
1531
+ },
1532
+ [load]
1533
+ );
1534
+ const handleDisconnect = (0, import_react9.useCallback)(
1535
+ async (entry) => {
1536
+ setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "disconnecting" } }));
1537
+ try {
1538
+ await clientRef.current.disconnect(entry);
1539
+ await load();
1540
+ setUiState((prev) => {
1541
+ const next = { ...prev };
1542
+ delete next[entry.slug];
1543
+ return next;
1544
+ });
1545
+ } catch (err) {
1546
+ const message = errorMessageFrom(err, DEFAULT_DISCONNECT_ERROR);
1547
+ setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "error", message } }));
1548
+ }
1549
+ },
1550
+ [load]
1551
+ );
1552
+ if (gated) return null;
1553
+ const containerClassName = cn(
1554
+ "flex flex-col",
1555
+ layout === "tiles" ? "gap-3 sm:grid sm:grid-cols-2 lg:grid-cols-3" : "divide-y divide-border"
1556
+ );
1557
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { "data-testid": "connector-picker", "data-layout": layout, className: containerClassName, children: [
1558
+ entries === null && !loadError && Array.from({ length: SKELETON_COUNT }).map((_, i) => /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
1559
+ "div",
1560
+ {
1561
+ "data-testid": "connector-picker-skeleton-item",
1562
+ "aria-hidden": "true",
1563
+ className: "flex items-center gap-3 rounded-lg border border-border p-3",
1564
+ children: [
1565
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { className: "h-8 w-8 shrink-0 animate-pulse rounded bg-foreground/10" }),
1566
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { className: "h-3 flex-1 animate-pulse rounded bg-foreground/10" })
1567
+ ]
1568
+ },
1569
+ i
1570
+ )),
1571
+ loadError && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
1572
+ "div",
1573
+ {
1574
+ role: "alert",
1575
+ "data-testid": "connector-picker-load-error",
1576
+ className: "flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive",
1577
+ children: [
1578
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { children: loadError }),
1579
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("button", { type: "button", onClick: () => void load(), className: "shrink-0 underline", children: "Retry" })
1580
+ ]
1581
+ }
1582
+ ),
1583
+ entries !== null && entries.length === 0 && !loadError && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { "data-testid": "connector-picker-empty", className: "text-sm text-muted-foreground", children: "No connections available." }),
1584
+ entries?.map((entry) => /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1585
+ ConnectorTile,
1586
+ {
1587
+ entry,
1588
+ ui: uiState[entry.slug],
1589
+ layout,
1590
+ onConnect: handleConnect,
1591
+ onDisconnect: handleDisconnect
1592
+ },
1593
+ entry.slug
1594
+ ))
1595
+ ] });
1596
+ }
1597
+ function ConnectorTile({
1598
+ entry,
1599
+ ui,
1600
+ layout,
1601
+ onConnect,
1602
+ onDisconnect
1603
+ }) {
1604
+ const isConnected = entry.connection.status === "connected";
1605
+ const isConnecting = ui?.kind === "connecting";
1606
+ const isDisconnecting = ui?.kind === "disconnecting";
1607
+ const errorMessage = ui?.kind === "error" ? ui.message : null;
1608
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
1609
+ "div",
1610
+ {
1611
+ "data-testid": `connector-tile-${entry.slug}`,
1612
+ "data-connected": isConnected || void 0,
1613
+ "data-deprecated": entry.deprecated || void 0,
1614
+ className: cn("flex items-center gap-3 p-3", layout === "tiles" && "rounded-lg border border-border"),
1615
+ children: [
1616
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(BrandGlyph, { brand: { name: entry.name, logo: entry.logo }, size: "lg" }),
1617
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "min-w-0 flex-1", children: [
1618
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "flex items-center gap-2", children: [
1619
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { className: "truncate text-sm font-medium", children: entry.name }),
1620
+ entry.deprecated && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1621
+ "span",
1622
+ {
1623
+ "data-testid": `connector-deprecated-${entry.slug}`,
1624
+ className: "shrink-0 rounded-full bg-foreground/10 px-2 py-0.5 text-[11px] text-muted-foreground",
1625
+ children: "No longer available"
1626
+ }
1627
+ )
1628
+ ] }),
1629
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [
1630
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { "data-testid": `connector-capability-${entry.slug}`, children: capabilityLabel(entry.capability) }),
1631
+ entry.category && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(import_jsx_runtime7.Fragment, { children: [
1632
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { "aria-hidden": "true", children: "\xB7" }),
1633
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { className: "truncate", children: entry.category })
1634
+ ] })
1635
+ ] }),
1636
+ isConnected && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1637
+ "p",
1638
+ {
1639
+ "data-testid": `connector-connected-since-${entry.slug}`,
1640
+ className: "text-xs text-muted-foreground",
1641
+ children: entry.connection.since ? `Connected since ${formatSince(entry.connection.since)}` : "Connected"
1642
+ }
1643
+ ),
1644
+ errorMessage && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { role: "alert", "data-testid": `connector-error-${entry.slug}`, className: "text-xs text-destructive", children: errorMessage })
1645
+ ] }),
1646
+ isConnected ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1647
+ "button",
1648
+ {
1649
+ type: "button",
1650
+ "data-testid": `connector-disconnect-${entry.slug}`,
1651
+ onClick: () => onDisconnect(entry),
1652
+ disabled: isDisconnecting,
1653
+ className: "shrink-0 rounded-md px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50",
1654
+ children: isDisconnecting ? "Disconnecting\u2026" : "Disconnect"
1655
+ }
1656
+ ) : /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1657
+ "button",
1658
+ {
1659
+ type: "button",
1660
+ "data-testid": `connector-connect-${entry.slug}`,
1661
+ onClick: () => onConnect(entry),
1662
+ disabled: isConnecting,
1663
+ className: "shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50",
1664
+ children: isConnecting ? "Connecting\u2026" : "Connect"
1665
+ }
1666
+ )
1667
+ ]
1668
+ }
1669
+ );
1670
+ }
1398
1671
  // Annotate the CommonJS export names for ESM import in node:
1399
1672
  0 && (module.exports = {
1400
1673
  AppSidebar,
1401
1674
  BrandGlyph,
1675
+ ConnectorPicker,
1402
1676
  ContextSwitcher,
1403
1677
  Flyout,
1404
1678
  GLYPH_CANVAS_FILL,
package/dist/index.mjs CHANGED
@@ -784,13 +784,23 @@ function Flyout({
784
784
  className: cn(
785
785
  "group flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-sm transition-colors",
786
786
  collapsed && "justify-center px-0",
787
- active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
787
+ // DEV-6586: this trigger renders as a row inside AppSidebar's own
788
+ // `<nav>` (a `bg-sidebar` surface that stays dark navy in both
789
+ // themes) — same fix as NavLink, same measured ratios (default
790
+ // 8.05:1, hover 13.65:1, active 10.95:1). The flyout PANEL below
791
+ // is intentionally left on document tokens: it renders via
792
+ // `position: fixed`, floating over the main content area on
793
+ // `bg-background`, not over the sidebar.
794
+ active ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-sidebar-foreground/70 hover:bg-sidebar-foreground/5 hover:text-sidebar-foreground"
788
795
  ),
789
796
  children: [
790
797
  /* @__PURE__ */ jsx3(
791
798
  Icon,
792
799
  {
793
- className: cn("size-4 shrink-0", active ? "text-primary" : "text-muted-foreground"),
800
+ className: cn(
801
+ "size-4 shrink-0",
802
+ active ? "text-sidebar-accent-foreground" : "text-sidebar-foreground/70"
803
+ ),
794
804
  strokeWidth: 1.5,
795
805
  "aria-hidden": "true"
796
806
  }
@@ -873,13 +883,23 @@ function NavLink({
873
883
  className: cn(
874
884
  "group flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm transition-colors",
875
885
  collapsed && "justify-center px-0",
876
- active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
886
+ // DEV-6586: the sidebar is a permanently dark-navy surface in BOTH
887
+ // app themes (see `--sidebar-*` in globals.css) — styling from
888
+ // document-theme tokens (text-foreground/text-muted-foreground) put
889
+ // dark-on-dark text here in light mode. `--sidebar-foreground` /
890
+ // `--sidebar-accent(-foreground)` are theme-invariant, so hover/
891
+ // active reliably INCREASE legibility instead of erasing it:
892
+ // default 8.05:1, hover 13.65:1, active 10.95:1 (all >= 4.5:1 AA).
893
+ active ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-sidebar-foreground/70 hover:bg-sidebar-foreground/5 hover:text-sidebar-foreground"
877
894
  ),
878
895
  children: [
879
896
  /* @__PURE__ */ jsx4(
880
897
  Icon,
881
898
  {
882
- className: cn("size-4 shrink-0", active ? "text-primary" : "text-muted-foreground"),
899
+ className: cn(
900
+ "size-4 shrink-0",
901
+ active ? "text-sidebar-accent-foreground" : "text-sidebar-foreground/70"
902
+ ),
883
903
  strokeWidth: 1.5,
884
904
  "aria-hidden": "true"
885
905
  }
@@ -1004,11 +1024,11 @@ function AppSidebar({
1004
1024
  label: item.label,
1005
1025
  icon: item.icon,
1006
1026
  active,
1007
- children,
1008
1027
  linkComponent,
1009
1028
  collapsed: collapsedRow,
1010
1029
  forceClosed: openFlyoutId !== null && openFlyoutId !== item.id,
1011
- onOpenChange: (isOpen) => handleFlyoutOpenChange(item.id, isOpen)
1030
+ onOpenChange: (isOpen) => handleFlyoutOpenChange(item.id, isOpen),
1031
+ children
1012
1032
  },
1013
1033
  item.id
1014
1034
  );
@@ -1022,7 +1042,7 @@ function AppSidebar({
1022
1042
  href: brand.href,
1023
1043
  "data-testid": "app-sidebar-brand",
1024
1044
  title: brand.label,
1025
- className: "flex items-center justify-center border-b border-border px-1 py-4",
1045
+ className: "flex items-center justify-center border-b border-sidebar-border px-1 py-4",
1026
1046
  children: [
1027
1047
  /* @__PURE__ */ jsx4("span", { className: "sr-only", children: brand.label }),
1028
1048
  /* @__PURE__ */ jsx4(BrandGlyph, { brand: { name: brand.label }, size: "xl" })
@@ -1035,9 +1055,9 @@ function AppSidebar({
1035
1055
  {
1036
1056
  href: brand.href,
1037
1057
  "data-testid": "app-sidebar-brand",
1038
- className: "flex items-center gap-2.5 border-b border-border px-5 py-4",
1058
+ className: "flex items-center gap-2.5 border-b border-sidebar-border px-5 py-4 text-sidebar-foreground",
1039
1059
  children: [
1040
- /* @__PURE__ */ jsx4("span", { className: "grid size-7 place-items-center rounded-md bg-primary text-primary-foreground", children: /* @__PURE__ */ jsx4("span", { className: "font-mono text-sm font-bold", children: (brand.label[0] ?? "r").toUpperCase() }) }),
1060
+ /* @__PURE__ */ jsx4("span", { className: "grid size-7 place-items-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground", children: /* @__PURE__ */ jsx4("span", { className: "font-mono text-sm font-bold", children: (brand.label[0] ?? "r").toUpperCase() }) }),
1041
1061
  /* @__PURE__ */ jsx4("span", { className: "truncate text-sm font-semibold tracking-tight", children: brand.label })
1042
1062
  ]
1043
1063
  }
@@ -1093,7 +1113,7 @@ function AppSidebar({
1093
1113
  "aria-modal": "true",
1094
1114
  "aria-label": "Navigation",
1095
1115
  "data-testid": "app-sidebar-mobile-drawer",
1096
- className: "absolute inset-y-0 left-0 flex w-72 flex-col overflow-y-auto border-r border-border bg-sidebar",
1116
+ className: "absolute inset-y-0 left-0 flex w-72 flex-col overflow-y-auto border-r border-sidebar-border bg-sidebar",
1097
1117
  children: [
1098
1118
  /* @__PURE__ */ jsx4("div", { className: "sticky top-0 z-10 flex justify-end bg-sidebar p-2", children: /* @__PURE__ */ jsx4(
1099
1119
  "button",
@@ -1104,7 +1124,7 @@ function AppSidebar({
1104
1124
  "aria-label": `Close ${menuLabel.toLowerCase()}`,
1105
1125
  onClick: () => setMobileOpen(false),
1106
1126
  style: TOUCH_TARGET_STYLE,
1107
- className: "flex items-center justify-center rounded-md p-2 text-foreground hover:bg-foreground/5",
1127
+ className: "flex items-center justify-center rounded-md p-2 text-sidebar-foreground/70 hover:bg-sidebar-foreground/5 hover:text-sidebar-foreground",
1108
1128
  children: /* @__PURE__ */ jsx4(X2, { className: "h-5 w-5", "aria-hidden": "true" })
1109
1129
  }
1110
1130
  ) }),
@@ -1122,7 +1142,7 @@ function AppSidebar({
1122
1142
  {
1123
1143
  "data-testid": "app-sidebar-desktop",
1124
1144
  className: cn(
1125
- "hidden shrink-0 flex-col border-r border-border bg-sidebar md:flex",
1145
+ "hidden shrink-0 flex-col border-r border-sidebar-border bg-sidebar md:flex",
1126
1146
  collapsed ? COLLAPSED_WIDTH_CLASS : EXPANDED_WIDTH_CLASS,
1127
1147
  // DEV-5331: a plain CSS width transition is enough — no new deps
1128
1148
  // for a spring/JS animation. Respect prefers-reduced-motion.
@@ -1143,7 +1163,7 @@ function AppSidebar({
1143
1163
  ) })
1144
1164
  }
1145
1165
  ),
1146
- collapsible && /* @__PURE__ */ jsx4("div", { className: "border-t border-border px-2 py-2", children: /* @__PURE__ */ jsxs4(
1166
+ collapsible && /* @__PURE__ */ jsx4("div", { className: "border-t border-sidebar-border px-2 py-2", children: /* @__PURE__ */ jsxs4(
1147
1167
  "button",
1148
1168
  {
1149
1169
  type: "button",
@@ -1153,7 +1173,7 @@ function AppSidebar({
1153
1173
  title: collapsed ? "Expand sidebar" : "Collapse sidebar",
1154
1174
  onClick: handleToggleCollapsed,
1155
1175
  className: cn(
1156
- "flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground",
1176
+ "flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-foreground/5 hover:text-sidebar-foreground",
1157
1177
  collapsed && "justify-center px-0"
1158
1178
  ),
1159
1179
  children: [
@@ -1291,18 +1311,18 @@ function ContextSwitcher({
1291
1311
  type: "button",
1292
1312
  title: active?.name || placeholder,
1293
1313
  "aria-label": active?.name || placeholder,
1294
- className: "flex h-10 w-10 items-center justify-center rounded-lg text-zinc-700 transition-colors hover:bg-zinc-900/5 dark:text-zinc-300 dark:hover:bg-white/5",
1314
+ className: "flex h-10 w-10 items-center justify-center rounded-lg text-sidebar-foreground/70 transition-colors hover:bg-sidebar-foreground/5 hover:text-sidebar-foreground",
1295
1315
  children: /* @__PURE__ */ jsx6(BrandGlyph, { brand: toBrand(active ?? { name: "" }), size: "lg" })
1296
1316
  }
1297
1317
  ) : /* @__PURE__ */ jsxs5(
1298
1318
  "button",
1299
1319
  {
1300
1320
  type: "button",
1301
- className: "group flex items-center gap-2 rounded-md border border-border bg-card/60 px-2.5 py-1.5 text-sm font-medium transition-colors hover:bg-muted",
1321
+ className: "group flex items-center gap-2 rounded-md border border-sidebar-border bg-sidebar-accent/60 px-2.5 py-1.5 text-sm font-medium transition-colors hover:bg-sidebar",
1302
1322
  children: [
1303
1323
  /* @__PURE__ */ jsx6(BrandGlyph, { brand: toBrand(active ?? { name: "" }) }),
1304
- /* @__PURE__ */ jsx6("span", { className: "max-w-[140px] truncate text-foreground/90", children: active?.name || placeholder }),
1305
- /* @__PURE__ */ jsx6(ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" })
1324
+ /* @__PURE__ */ jsx6("span", { className: "max-w-[140px] truncate text-sidebar-accent-foreground", children: active?.name || placeholder }),
1325
+ /* @__PURE__ */ jsx6(ChevronDown, { className: "h-3.5 w-3.5 text-sidebar-foreground/70 transition-transform group-data-[state=open]:rotate-180" })
1306
1326
  ]
1307
1327
  }
1308
1328
  ) }),
@@ -1341,9 +1361,262 @@ function ContextSwitcher({
1341
1361
  )
1342
1362
  ] });
1343
1363
  }
1364
+
1365
+ // src/connector-picker.tsx
1366
+ import { useCallback as useCallback4, useEffect as useEffect6, useRef as useRef5, useState as useState7 } from "react";
1367
+ import { Fragment as Fragment3, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
1368
+ var DEFAULT_CONNECT_ERROR = "Couldn't start the connection. Please try again.";
1369
+ var DEFAULT_DISCONNECT_ERROR = "Couldn't disconnect. Please try again.";
1370
+ var DEFAULT_LOAD_ERROR = "Couldn't load connections. Please try again.";
1371
+ var SKELETON_COUNT = 3;
1372
+ function capabilityLabel(capability) {
1373
+ if (capability === "read") return "Read only";
1374
+ if (capability === "write") return "Write only";
1375
+ return "Read & write";
1376
+ }
1377
+ function formatSince(since) {
1378
+ const parsed = new Date(since);
1379
+ if (Number.isNaN(parsed.getTime())) return since;
1380
+ return parsed.toLocaleDateString(void 0, { year: "numeric", month: "short", day: "numeric" });
1381
+ }
1382
+ function errorMessageFrom(err, fallback) {
1383
+ return err instanceof Error && err.message ? err.message : fallback;
1384
+ }
1385
+ function ConnectorPicker({
1386
+ client,
1387
+ purpose,
1388
+ lens = "owner",
1389
+ layout = "tiles",
1390
+ onConnected
1391
+ }) {
1392
+ const gated = lens !== "owner";
1393
+ const [entries, setEntries] = useState7(null);
1394
+ const [loadError, setLoadError] = useState7(null);
1395
+ const [uiState, setUiState] = useState7({});
1396
+ const clientRef = useRef5(client);
1397
+ clientRef.current = client;
1398
+ const purposeRef = useRef5(purpose);
1399
+ purposeRef.current = purpose;
1400
+ const onConnectedRef = useRef5(onConnected);
1401
+ onConnectedRef.current = onConnected;
1402
+ const uiStateRef = useRef5(uiState);
1403
+ uiStateRef.current = uiState;
1404
+ const load = useCallback4(async () => {
1405
+ if (gated) return null;
1406
+ try {
1407
+ const list = await clientRef.current.listConnectable({ purpose: purposeRef.current });
1408
+ setEntries(list);
1409
+ setLoadError(null);
1410
+ return list;
1411
+ } catch (err) {
1412
+ setLoadError(errorMessageFrom(err, DEFAULT_LOAD_ERROR));
1413
+ return null;
1414
+ }
1415
+ }, [gated]);
1416
+ useEffect6(() => {
1417
+ if (gated) return;
1418
+ void load();
1419
+ }, [gated, load, purpose]);
1420
+ const refreshAfterReturn = useCallback4(async () => {
1421
+ const list = await load();
1422
+ if (!list) return;
1423
+ setUiState((prev) => {
1424
+ let changed = false;
1425
+ const next = { ...prev };
1426
+ for (const entry of list) {
1427
+ if (next[entry.slug]?.kind === "connecting" && entry.connection.status === "connected") {
1428
+ changed = true;
1429
+ delete next[entry.slug];
1430
+ onConnectedRef.current?.(entry);
1431
+ }
1432
+ }
1433
+ return changed ? next : prev;
1434
+ });
1435
+ }, [load]);
1436
+ useEffect6(() => {
1437
+ if (gated) return;
1438
+ function handleReturn() {
1439
+ if (document.visibilityState === "hidden") return;
1440
+ const hasPendingConnect = Object.values(uiStateRef.current).some(
1441
+ (s) => s.kind === "connecting"
1442
+ );
1443
+ if (!hasPendingConnect) return;
1444
+ void refreshAfterReturn();
1445
+ }
1446
+ window.addEventListener("focus", handleReturn);
1447
+ document.addEventListener("visibilitychange", handleReturn);
1448
+ return () => {
1449
+ window.removeEventListener("focus", handleReturn);
1450
+ document.removeEventListener("visibilitychange", handleReturn);
1451
+ };
1452
+ }, [gated, refreshAfterReturn]);
1453
+ const handleConnect = useCallback4(
1454
+ async (entry) => {
1455
+ setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "connecting" } }));
1456
+ try {
1457
+ const result = await clientRef.current.connect(entry);
1458
+ if (result?.connect_url) {
1459
+ window.open(result.connect_url, "_blank", "noopener,noreferrer");
1460
+ return;
1461
+ }
1462
+ const list = await load();
1463
+ const updated = list?.find((e) => e.slug === entry.slug) ?? null;
1464
+ setUiState((prev) => {
1465
+ const next = { ...prev };
1466
+ delete next[entry.slug];
1467
+ return next;
1468
+ });
1469
+ if (updated?.connection.status === "connected") {
1470
+ onConnectedRef.current?.(updated);
1471
+ }
1472
+ } catch (err) {
1473
+ const message = errorMessageFrom(err, DEFAULT_CONNECT_ERROR);
1474
+ setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "error", message } }));
1475
+ }
1476
+ },
1477
+ [load]
1478
+ );
1479
+ const handleDisconnect = useCallback4(
1480
+ async (entry) => {
1481
+ setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "disconnecting" } }));
1482
+ try {
1483
+ await clientRef.current.disconnect(entry);
1484
+ await load();
1485
+ setUiState((prev) => {
1486
+ const next = { ...prev };
1487
+ delete next[entry.slug];
1488
+ return next;
1489
+ });
1490
+ } catch (err) {
1491
+ const message = errorMessageFrom(err, DEFAULT_DISCONNECT_ERROR);
1492
+ setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "error", message } }));
1493
+ }
1494
+ },
1495
+ [load]
1496
+ );
1497
+ if (gated) return null;
1498
+ const containerClassName = cn(
1499
+ "flex flex-col",
1500
+ layout === "tiles" ? "gap-3 sm:grid sm:grid-cols-2 lg:grid-cols-3" : "divide-y divide-border"
1501
+ );
1502
+ return /* @__PURE__ */ jsxs6("div", { "data-testid": "connector-picker", "data-layout": layout, className: containerClassName, children: [
1503
+ entries === null && !loadError && Array.from({ length: SKELETON_COUNT }).map((_, i) => /* @__PURE__ */ jsxs6(
1504
+ "div",
1505
+ {
1506
+ "data-testid": "connector-picker-skeleton-item",
1507
+ "aria-hidden": "true",
1508
+ className: "flex items-center gap-3 rounded-lg border border-border p-3",
1509
+ children: [
1510
+ /* @__PURE__ */ jsx7("span", { className: "h-8 w-8 shrink-0 animate-pulse rounded bg-foreground/10" }),
1511
+ /* @__PURE__ */ jsx7("span", { className: "h-3 flex-1 animate-pulse rounded bg-foreground/10" })
1512
+ ]
1513
+ },
1514
+ i
1515
+ )),
1516
+ loadError && /* @__PURE__ */ jsxs6(
1517
+ "div",
1518
+ {
1519
+ role: "alert",
1520
+ "data-testid": "connector-picker-load-error",
1521
+ className: "flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive",
1522
+ children: [
1523
+ /* @__PURE__ */ jsx7("span", { children: loadError }),
1524
+ /* @__PURE__ */ jsx7("button", { type: "button", onClick: () => void load(), className: "shrink-0 underline", children: "Retry" })
1525
+ ]
1526
+ }
1527
+ ),
1528
+ entries !== null && entries.length === 0 && !loadError && /* @__PURE__ */ jsx7("p", { "data-testid": "connector-picker-empty", className: "text-sm text-muted-foreground", children: "No connections available." }),
1529
+ entries?.map((entry) => /* @__PURE__ */ jsx7(
1530
+ ConnectorTile,
1531
+ {
1532
+ entry,
1533
+ ui: uiState[entry.slug],
1534
+ layout,
1535
+ onConnect: handleConnect,
1536
+ onDisconnect: handleDisconnect
1537
+ },
1538
+ entry.slug
1539
+ ))
1540
+ ] });
1541
+ }
1542
+ function ConnectorTile({
1543
+ entry,
1544
+ ui,
1545
+ layout,
1546
+ onConnect,
1547
+ onDisconnect
1548
+ }) {
1549
+ const isConnected = entry.connection.status === "connected";
1550
+ const isConnecting = ui?.kind === "connecting";
1551
+ const isDisconnecting = ui?.kind === "disconnecting";
1552
+ const errorMessage = ui?.kind === "error" ? ui.message : null;
1553
+ return /* @__PURE__ */ jsxs6(
1554
+ "div",
1555
+ {
1556
+ "data-testid": `connector-tile-${entry.slug}`,
1557
+ "data-connected": isConnected || void 0,
1558
+ "data-deprecated": entry.deprecated || void 0,
1559
+ className: cn("flex items-center gap-3 p-3", layout === "tiles" && "rounded-lg border border-border"),
1560
+ children: [
1561
+ /* @__PURE__ */ jsx7(BrandGlyph, { brand: { name: entry.name, logo: entry.logo }, size: "lg" }),
1562
+ /* @__PURE__ */ jsxs6("div", { className: "min-w-0 flex-1", children: [
1563
+ /* @__PURE__ */ jsxs6("div", { className: "flex items-center gap-2", children: [
1564
+ /* @__PURE__ */ jsx7("p", { className: "truncate text-sm font-medium", children: entry.name }),
1565
+ entry.deprecated && /* @__PURE__ */ jsx7(
1566
+ "span",
1567
+ {
1568
+ "data-testid": `connector-deprecated-${entry.slug}`,
1569
+ className: "shrink-0 rounded-full bg-foreground/10 px-2 py-0.5 text-[11px] text-muted-foreground",
1570
+ children: "No longer available"
1571
+ }
1572
+ )
1573
+ ] }),
1574
+ /* @__PURE__ */ jsxs6("div", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [
1575
+ /* @__PURE__ */ jsx7("span", { "data-testid": `connector-capability-${entry.slug}`, children: capabilityLabel(entry.capability) }),
1576
+ entry.category && /* @__PURE__ */ jsxs6(Fragment3, { children: [
1577
+ /* @__PURE__ */ jsx7("span", { "aria-hidden": "true", children: "\xB7" }),
1578
+ /* @__PURE__ */ jsx7("span", { className: "truncate", children: entry.category })
1579
+ ] })
1580
+ ] }),
1581
+ isConnected && /* @__PURE__ */ jsx7(
1582
+ "p",
1583
+ {
1584
+ "data-testid": `connector-connected-since-${entry.slug}`,
1585
+ className: "text-xs text-muted-foreground",
1586
+ children: entry.connection.since ? `Connected since ${formatSince(entry.connection.since)}` : "Connected"
1587
+ }
1588
+ ),
1589
+ errorMessage && /* @__PURE__ */ jsx7("p", { role: "alert", "data-testid": `connector-error-${entry.slug}`, className: "text-xs text-destructive", children: errorMessage })
1590
+ ] }),
1591
+ isConnected ? /* @__PURE__ */ jsx7(
1592
+ "button",
1593
+ {
1594
+ type: "button",
1595
+ "data-testid": `connector-disconnect-${entry.slug}`,
1596
+ onClick: () => onDisconnect(entry),
1597
+ disabled: isDisconnecting,
1598
+ className: "shrink-0 rounded-md px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50",
1599
+ children: isDisconnecting ? "Disconnecting\u2026" : "Disconnect"
1600
+ }
1601
+ ) : /* @__PURE__ */ jsx7(
1602
+ "button",
1603
+ {
1604
+ type: "button",
1605
+ "data-testid": `connector-connect-${entry.slug}`,
1606
+ onClick: () => onConnect(entry),
1607
+ disabled: isConnecting,
1608
+ className: "shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50",
1609
+ children: isConnecting ? "Connecting\u2026" : "Connect"
1610
+ }
1611
+ )
1612
+ ]
1613
+ }
1614
+ );
1615
+ }
1344
1616
  export {
1345
1617
  AppSidebar,
1346
1618
  BrandGlyph,
1619
+ ConnectorPicker,
1347
1620
  ContextSwitcher,
1348
1621
  Flyout,
1349
1622
  GLYPH_CANVAS_FILL,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meetreeve/ui",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "Shared brand-adaptive UI primitives for MindFortress frontends — min-size tokens, a favicon→logo→monogram BrandGlyph, a responsive header that keeps the search bar usable on mobile, and AppSidebar's viewport-driven auto-collapse ladder.",
5
5
  "license": "UNLICENSED",
6
6
  "main": "./dist/index.js",
@@ -35,7 +35,7 @@
35
35
  "dev": "tsup --watch",
36
36
  "test": "vitest run",
37
37
  "typecheck": "tsc --noEmit",
38
- "check:esm": "node scripts/check-esm-import.mjs",
38
+ "check:esm": "node ../../scripts/check-esm-import.mjs .",
39
39
  "prepublishOnly": "npm run build"
40
40
  },
41
41
  "publishConfig": {
@@ -55,6 +55,7 @@
55
55
  "@testing-library/dom": "^10.4.1",
56
56
  "@testing-library/react": "^16.3.2",
57
57
  "@testing-library/user-event": "^14.6.1",
58
+ "@types/node": "^20.19.43",
58
59
  "@types/react": "^19",
59
60
  "@types/react-dom": "^19",
60
61
  "@vitejs/plugin-react": "^5.1.3",