@meetreeve/ui 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,
@@ -1058,11 +1059,11 @@ function AppSidebar({
1058
1059
  label: item.label,
1059
1060
  icon: item.icon,
1060
1061
  active,
1061
- children,
1062
1062
  linkComponent,
1063
1063
  collapsed: collapsedRow,
1064
1064
  forceClosed: openFlyoutId !== null && openFlyoutId !== item.id,
1065
- onOpenChange: (isOpen) => handleFlyoutOpenChange(item.id, isOpen)
1065
+ onOpenChange: (isOpen) => handleFlyoutOpenChange(item.id, isOpen),
1066
+ children
1066
1067
  },
1067
1068
  item.id
1068
1069
  );
@@ -1395,10 +1396,263 @@ function ContextSwitcher({
1395
1396
  )
1396
1397
  ] });
1397
1398
  }
1399
+
1400
+ // src/connector-picker.tsx
1401
+ var import_react9 = require("react");
1402
+ var import_jsx_runtime7 = require("react/jsx-runtime");
1403
+ var DEFAULT_CONNECT_ERROR = "Couldn't start the connection. Please try again.";
1404
+ var DEFAULT_DISCONNECT_ERROR = "Couldn't disconnect. Please try again.";
1405
+ var DEFAULT_LOAD_ERROR = "Couldn't load connections. Please try again.";
1406
+ var SKELETON_COUNT = 3;
1407
+ function capabilityLabel(capability) {
1408
+ if (capability === "read") return "Read only";
1409
+ if (capability === "write") return "Write only";
1410
+ return "Read & write";
1411
+ }
1412
+ function formatSince(since) {
1413
+ const parsed = new Date(since);
1414
+ if (Number.isNaN(parsed.getTime())) return since;
1415
+ return parsed.toLocaleDateString(void 0, { year: "numeric", month: "short", day: "numeric" });
1416
+ }
1417
+ function errorMessageFrom(err, fallback) {
1418
+ return err instanceof Error && err.message ? err.message : fallback;
1419
+ }
1420
+ function ConnectorPicker({
1421
+ client,
1422
+ purpose,
1423
+ lens = "owner",
1424
+ layout = "tiles",
1425
+ onConnected
1426
+ }) {
1427
+ const gated = lens !== "owner";
1428
+ const [entries, setEntries] = (0, import_react9.useState)(null);
1429
+ const [loadError, setLoadError] = (0, import_react9.useState)(null);
1430
+ const [uiState, setUiState] = (0, import_react9.useState)({});
1431
+ const clientRef = (0, import_react9.useRef)(client);
1432
+ clientRef.current = client;
1433
+ const purposeRef = (0, import_react9.useRef)(purpose);
1434
+ purposeRef.current = purpose;
1435
+ const onConnectedRef = (0, import_react9.useRef)(onConnected);
1436
+ onConnectedRef.current = onConnected;
1437
+ const uiStateRef = (0, import_react9.useRef)(uiState);
1438
+ uiStateRef.current = uiState;
1439
+ const load = (0, import_react9.useCallback)(async () => {
1440
+ if (gated) return null;
1441
+ try {
1442
+ const list = await clientRef.current.listConnectable({ purpose: purposeRef.current });
1443
+ setEntries(list);
1444
+ setLoadError(null);
1445
+ return list;
1446
+ } catch (err) {
1447
+ setLoadError(errorMessageFrom(err, DEFAULT_LOAD_ERROR));
1448
+ return null;
1449
+ }
1450
+ }, [gated]);
1451
+ (0, import_react9.useEffect)(() => {
1452
+ if (gated) return;
1453
+ void load();
1454
+ }, [gated, load, purpose]);
1455
+ const refreshAfterReturn = (0, import_react9.useCallback)(async () => {
1456
+ const list = await load();
1457
+ if (!list) return;
1458
+ setUiState((prev) => {
1459
+ let changed = false;
1460
+ const next = { ...prev };
1461
+ for (const entry of list) {
1462
+ if (next[entry.slug]?.kind === "connecting" && entry.connection.status === "connected") {
1463
+ changed = true;
1464
+ delete next[entry.slug];
1465
+ onConnectedRef.current?.(entry);
1466
+ }
1467
+ }
1468
+ return changed ? next : prev;
1469
+ });
1470
+ }, [load]);
1471
+ (0, import_react9.useEffect)(() => {
1472
+ if (gated) return;
1473
+ function handleReturn() {
1474
+ if (document.visibilityState === "hidden") return;
1475
+ const hasPendingConnect = Object.values(uiStateRef.current).some(
1476
+ (s) => s.kind === "connecting"
1477
+ );
1478
+ if (!hasPendingConnect) return;
1479
+ void refreshAfterReturn();
1480
+ }
1481
+ window.addEventListener("focus", handleReturn);
1482
+ document.addEventListener("visibilitychange", handleReturn);
1483
+ return () => {
1484
+ window.removeEventListener("focus", handleReturn);
1485
+ document.removeEventListener("visibilitychange", handleReturn);
1486
+ };
1487
+ }, [gated, refreshAfterReturn]);
1488
+ const handleConnect = (0, import_react9.useCallback)(
1489
+ async (entry) => {
1490
+ setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "connecting" } }));
1491
+ try {
1492
+ const result = await clientRef.current.connect(entry);
1493
+ if (result?.connect_url) {
1494
+ window.open(result.connect_url, "_blank", "noopener,noreferrer");
1495
+ return;
1496
+ }
1497
+ const list = await load();
1498
+ const updated = list?.find((e) => e.slug === entry.slug) ?? null;
1499
+ setUiState((prev) => {
1500
+ const next = { ...prev };
1501
+ delete next[entry.slug];
1502
+ return next;
1503
+ });
1504
+ if (updated?.connection.status === "connected") {
1505
+ onConnectedRef.current?.(updated);
1506
+ }
1507
+ } catch (err) {
1508
+ const message = errorMessageFrom(err, DEFAULT_CONNECT_ERROR);
1509
+ setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "error", message } }));
1510
+ }
1511
+ },
1512
+ [load]
1513
+ );
1514
+ const handleDisconnect = (0, import_react9.useCallback)(
1515
+ async (entry) => {
1516
+ setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "disconnecting" } }));
1517
+ try {
1518
+ await clientRef.current.disconnect(entry);
1519
+ await load();
1520
+ setUiState((prev) => {
1521
+ const next = { ...prev };
1522
+ delete next[entry.slug];
1523
+ return next;
1524
+ });
1525
+ } catch (err) {
1526
+ const message = errorMessageFrom(err, DEFAULT_DISCONNECT_ERROR);
1527
+ setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "error", message } }));
1528
+ }
1529
+ },
1530
+ [load]
1531
+ );
1532
+ if (gated) return null;
1533
+ const containerClassName = cn(
1534
+ "flex flex-col",
1535
+ layout === "tiles" ? "gap-3 sm:grid sm:grid-cols-2 lg:grid-cols-3" : "divide-y divide-border"
1536
+ );
1537
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { "data-testid": "connector-picker", "data-layout": layout, className: containerClassName, children: [
1538
+ entries === null && !loadError && Array.from({ length: SKELETON_COUNT }).map((_, i) => /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
1539
+ "div",
1540
+ {
1541
+ "data-testid": "connector-picker-skeleton-item",
1542
+ "aria-hidden": "true",
1543
+ className: "flex items-center gap-3 rounded-lg border border-border p-3",
1544
+ children: [
1545
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { className: "h-8 w-8 shrink-0 animate-pulse rounded bg-foreground/10" }),
1546
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { className: "h-3 flex-1 animate-pulse rounded bg-foreground/10" })
1547
+ ]
1548
+ },
1549
+ i
1550
+ )),
1551
+ loadError && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
1552
+ "div",
1553
+ {
1554
+ role: "alert",
1555
+ "data-testid": "connector-picker-load-error",
1556
+ className: "flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive",
1557
+ children: [
1558
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { children: loadError }),
1559
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("button", { type: "button", onClick: () => void load(), className: "shrink-0 underline", children: "Retry" })
1560
+ ]
1561
+ }
1562
+ ),
1563
+ 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." }),
1564
+ entries?.map((entry) => /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1565
+ ConnectorTile,
1566
+ {
1567
+ entry,
1568
+ ui: uiState[entry.slug],
1569
+ layout,
1570
+ onConnect: handleConnect,
1571
+ onDisconnect: handleDisconnect
1572
+ },
1573
+ entry.slug
1574
+ ))
1575
+ ] });
1576
+ }
1577
+ function ConnectorTile({
1578
+ entry,
1579
+ ui,
1580
+ layout,
1581
+ onConnect,
1582
+ onDisconnect
1583
+ }) {
1584
+ const isConnected = entry.connection.status === "connected";
1585
+ const isConnecting = ui?.kind === "connecting";
1586
+ const isDisconnecting = ui?.kind === "disconnecting";
1587
+ const errorMessage = ui?.kind === "error" ? ui.message : null;
1588
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
1589
+ "div",
1590
+ {
1591
+ "data-testid": `connector-tile-${entry.slug}`,
1592
+ "data-connected": isConnected || void 0,
1593
+ "data-deprecated": entry.deprecated || void 0,
1594
+ className: cn("flex items-center gap-3 p-3", layout === "tiles" && "rounded-lg border border-border"),
1595
+ children: [
1596
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(BrandGlyph, { brand: { name: entry.name, logo: entry.logo }, size: "lg" }),
1597
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "min-w-0 flex-1", children: [
1598
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "flex items-center gap-2", children: [
1599
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { className: "truncate text-sm font-medium", children: entry.name }),
1600
+ entry.deprecated && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1601
+ "span",
1602
+ {
1603
+ "data-testid": `connector-deprecated-${entry.slug}`,
1604
+ className: "shrink-0 rounded-full bg-foreground/10 px-2 py-0.5 text-[11px] text-muted-foreground",
1605
+ children: "No longer available"
1606
+ }
1607
+ )
1608
+ ] }),
1609
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [
1610
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { "data-testid": `connector-capability-${entry.slug}`, children: capabilityLabel(entry.capability) }),
1611
+ entry.category && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(import_jsx_runtime7.Fragment, { children: [
1612
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { "aria-hidden": "true", children: "\xB7" }),
1613
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { className: "truncate", children: entry.category })
1614
+ ] })
1615
+ ] }),
1616
+ isConnected && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1617
+ "p",
1618
+ {
1619
+ "data-testid": `connector-connected-since-${entry.slug}`,
1620
+ className: "text-xs text-muted-foreground",
1621
+ children: entry.connection.since ? `Connected since ${formatSince(entry.connection.since)}` : "Connected"
1622
+ }
1623
+ ),
1624
+ errorMessage && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { role: "alert", "data-testid": `connector-error-${entry.slug}`, className: "text-xs text-destructive", children: errorMessage })
1625
+ ] }),
1626
+ isConnected ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1627
+ "button",
1628
+ {
1629
+ type: "button",
1630
+ "data-testid": `connector-disconnect-${entry.slug}`,
1631
+ onClick: () => onDisconnect(entry),
1632
+ disabled: isDisconnecting,
1633
+ className: "shrink-0 rounded-md px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50",
1634
+ children: isDisconnecting ? "Disconnecting\u2026" : "Disconnect"
1635
+ }
1636
+ ) : /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1637
+ "button",
1638
+ {
1639
+ type: "button",
1640
+ "data-testid": `connector-connect-${entry.slug}`,
1641
+ onClick: () => onConnect(entry),
1642
+ disabled: isConnecting,
1643
+ className: "shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50",
1644
+ children: isConnecting ? "Connecting\u2026" : "Connect"
1645
+ }
1646
+ )
1647
+ ]
1648
+ }
1649
+ );
1650
+ }
1398
1651
  // Annotate the CommonJS export names for ESM import in node:
1399
1652
  0 && (module.exports = {
1400
1653
  AppSidebar,
1401
1654
  BrandGlyph,
1655
+ ConnectorPicker,
1402
1656
  ContextSwitcher,
1403
1657
  Flyout,
1404
1658
  GLYPH_CANVAS_FILL,
package/dist/index.mjs CHANGED
@@ -1004,11 +1004,11 @@ function AppSidebar({
1004
1004
  label: item.label,
1005
1005
  icon: item.icon,
1006
1006
  active,
1007
- children,
1008
1007
  linkComponent,
1009
1008
  collapsed: collapsedRow,
1010
1009
  forceClosed: openFlyoutId !== null && openFlyoutId !== item.id,
1011
- onOpenChange: (isOpen) => handleFlyoutOpenChange(item.id, isOpen)
1010
+ onOpenChange: (isOpen) => handleFlyoutOpenChange(item.id, isOpen),
1011
+ children
1012
1012
  },
1013
1013
  item.id
1014
1014
  );
@@ -1341,9 +1341,262 @@ function ContextSwitcher({
1341
1341
  )
1342
1342
  ] });
1343
1343
  }
1344
+
1345
+ // src/connector-picker.tsx
1346
+ import { useCallback as useCallback4, useEffect as useEffect6, useRef as useRef5, useState as useState7 } from "react";
1347
+ import { Fragment as Fragment3, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
1348
+ var DEFAULT_CONNECT_ERROR = "Couldn't start the connection. Please try again.";
1349
+ var DEFAULT_DISCONNECT_ERROR = "Couldn't disconnect. Please try again.";
1350
+ var DEFAULT_LOAD_ERROR = "Couldn't load connections. Please try again.";
1351
+ var SKELETON_COUNT = 3;
1352
+ function capabilityLabel(capability) {
1353
+ if (capability === "read") return "Read only";
1354
+ if (capability === "write") return "Write only";
1355
+ return "Read & write";
1356
+ }
1357
+ function formatSince(since) {
1358
+ const parsed = new Date(since);
1359
+ if (Number.isNaN(parsed.getTime())) return since;
1360
+ return parsed.toLocaleDateString(void 0, { year: "numeric", month: "short", day: "numeric" });
1361
+ }
1362
+ function errorMessageFrom(err, fallback) {
1363
+ return err instanceof Error && err.message ? err.message : fallback;
1364
+ }
1365
+ function ConnectorPicker({
1366
+ client,
1367
+ purpose,
1368
+ lens = "owner",
1369
+ layout = "tiles",
1370
+ onConnected
1371
+ }) {
1372
+ const gated = lens !== "owner";
1373
+ const [entries, setEntries] = useState7(null);
1374
+ const [loadError, setLoadError] = useState7(null);
1375
+ const [uiState, setUiState] = useState7({});
1376
+ const clientRef = useRef5(client);
1377
+ clientRef.current = client;
1378
+ const purposeRef = useRef5(purpose);
1379
+ purposeRef.current = purpose;
1380
+ const onConnectedRef = useRef5(onConnected);
1381
+ onConnectedRef.current = onConnected;
1382
+ const uiStateRef = useRef5(uiState);
1383
+ uiStateRef.current = uiState;
1384
+ const load = useCallback4(async () => {
1385
+ if (gated) return null;
1386
+ try {
1387
+ const list = await clientRef.current.listConnectable({ purpose: purposeRef.current });
1388
+ setEntries(list);
1389
+ setLoadError(null);
1390
+ return list;
1391
+ } catch (err) {
1392
+ setLoadError(errorMessageFrom(err, DEFAULT_LOAD_ERROR));
1393
+ return null;
1394
+ }
1395
+ }, [gated]);
1396
+ useEffect6(() => {
1397
+ if (gated) return;
1398
+ void load();
1399
+ }, [gated, load, purpose]);
1400
+ const refreshAfterReturn = useCallback4(async () => {
1401
+ const list = await load();
1402
+ if (!list) return;
1403
+ setUiState((prev) => {
1404
+ let changed = false;
1405
+ const next = { ...prev };
1406
+ for (const entry of list) {
1407
+ if (next[entry.slug]?.kind === "connecting" && entry.connection.status === "connected") {
1408
+ changed = true;
1409
+ delete next[entry.slug];
1410
+ onConnectedRef.current?.(entry);
1411
+ }
1412
+ }
1413
+ return changed ? next : prev;
1414
+ });
1415
+ }, [load]);
1416
+ useEffect6(() => {
1417
+ if (gated) return;
1418
+ function handleReturn() {
1419
+ if (document.visibilityState === "hidden") return;
1420
+ const hasPendingConnect = Object.values(uiStateRef.current).some(
1421
+ (s) => s.kind === "connecting"
1422
+ );
1423
+ if (!hasPendingConnect) return;
1424
+ void refreshAfterReturn();
1425
+ }
1426
+ window.addEventListener("focus", handleReturn);
1427
+ document.addEventListener("visibilitychange", handleReturn);
1428
+ return () => {
1429
+ window.removeEventListener("focus", handleReturn);
1430
+ document.removeEventListener("visibilitychange", handleReturn);
1431
+ };
1432
+ }, [gated, refreshAfterReturn]);
1433
+ const handleConnect = useCallback4(
1434
+ async (entry) => {
1435
+ setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "connecting" } }));
1436
+ try {
1437
+ const result = await clientRef.current.connect(entry);
1438
+ if (result?.connect_url) {
1439
+ window.open(result.connect_url, "_blank", "noopener,noreferrer");
1440
+ return;
1441
+ }
1442
+ const list = await load();
1443
+ const updated = list?.find((e) => e.slug === entry.slug) ?? null;
1444
+ setUiState((prev) => {
1445
+ const next = { ...prev };
1446
+ delete next[entry.slug];
1447
+ return next;
1448
+ });
1449
+ if (updated?.connection.status === "connected") {
1450
+ onConnectedRef.current?.(updated);
1451
+ }
1452
+ } catch (err) {
1453
+ const message = errorMessageFrom(err, DEFAULT_CONNECT_ERROR);
1454
+ setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "error", message } }));
1455
+ }
1456
+ },
1457
+ [load]
1458
+ );
1459
+ const handleDisconnect = useCallback4(
1460
+ async (entry) => {
1461
+ setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "disconnecting" } }));
1462
+ try {
1463
+ await clientRef.current.disconnect(entry);
1464
+ await load();
1465
+ setUiState((prev) => {
1466
+ const next = { ...prev };
1467
+ delete next[entry.slug];
1468
+ return next;
1469
+ });
1470
+ } catch (err) {
1471
+ const message = errorMessageFrom(err, DEFAULT_DISCONNECT_ERROR);
1472
+ setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "error", message } }));
1473
+ }
1474
+ },
1475
+ [load]
1476
+ );
1477
+ if (gated) return null;
1478
+ const containerClassName = cn(
1479
+ "flex flex-col",
1480
+ layout === "tiles" ? "gap-3 sm:grid sm:grid-cols-2 lg:grid-cols-3" : "divide-y divide-border"
1481
+ );
1482
+ return /* @__PURE__ */ jsxs6("div", { "data-testid": "connector-picker", "data-layout": layout, className: containerClassName, children: [
1483
+ entries === null && !loadError && Array.from({ length: SKELETON_COUNT }).map((_, i) => /* @__PURE__ */ jsxs6(
1484
+ "div",
1485
+ {
1486
+ "data-testid": "connector-picker-skeleton-item",
1487
+ "aria-hidden": "true",
1488
+ className: "flex items-center gap-3 rounded-lg border border-border p-3",
1489
+ children: [
1490
+ /* @__PURE__ */ jsx7("span", { className: "h-8 w-8 shrink-0 animate-pulse rounded bg-foreground/10" }),
1491
+ /* @__PURE__ */ jsx7("span", { className: "h-3 flex-1 animate-pulse rounded bg-foreground/10" })
1492
+ ]
1493
+ },
1494
+ i
1495
+ )),
1496
+ loadError && /* @__PURE__ */ jsxs6(
1497
+ "div",
1498
+ {
1499
+ role: "alert",
1500
+ "data-testid": "connector-picker-load-error",
1501
+ className: "flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive",
1502
+ children: [
1503
+ /* @__PURE__ */ jsx7("span", { children: loadError }),
1504
+ /* @__PURE__ */ jsx7("button", { type: "button", onClick: () => void load(), className: "shrink-0 underline", children: "Retry" })
1505
+ ]
1506
+ }
1507
+ ),
1508
+ entries !== null && entries.length === 0 && !loadError && /* @__PURE__ */ jsx7("p", { "data-testid": "connector-picker-empty", className: "text-sm text-muted-foreground", children: "No connections available." }),
1509
+ entries?.map((entry) => /* @__PURE__ */ jsx7(
1510
+ ConnectorTile,
1511
+ {
1512
+ entry,
1513
+ ui: uiState[entry.slug],
1514
+ layout,
1515
+ onConnect: handleConnect,
1516
+ onDisconnect: handleDisconnect
1517
+ },
1518
+ entry.slug
1519
+ ))
1520
+ ] });
1521
+ }
1522
+ function ConnectorTile({
1523
+ entry,
1524
+ ui,
1525
+ layout,
1526
+ onConnect,
1527
+ onDisconnect
1528
+ }) {
1529
+ const isConnected = entry.connection.status === "connected";
1530
+ const isConnecting = ui?.kind === "connecting";
1531
+ const isDisconnecting = ui?.kind === "disconnecting";
1532
+ const errorMessage = ui?.kind === "error" ? ui.message : null;
1533
+ return /* @__PURE__ */ jsxs6(
1534
+ "div",
1535
+ {
1536
+ "data-testid": `connector-tile-${entry.slug}`,
1537
+ "data-connected": isConnected || void 0,
1538
+ "data-deprecated": entry.deprecated || void 0,
1539
+ className: cn("flex items-center gap-3 p-3", layout === "tiles" && "rounded-lg border border-border"),
1540
+ children: [
1541
+ /* @__PURE__ */ jsx7(BrandGlyph, { brand: { name: entry.name, logo: entry.logo }, size: "lg" }),
1542
+ /* @__PURE__ */ jsxs6("div", { className: "min-w-0 flex-1", children: [
1543
+ /* @__PURE__ */ jsxs6("div", { className: "flex items-center gap-2", children: [
1544
+ /* @__PURE__ */ jsx7("p", { className: "truncate text-sm font-medium", children: entry.name }),
1545
+ entry.deprecated && /* @__PURE__ */ jsx7(
1546
+ "span",
1547
+ {
1548
+ "data-testid": `connector-deprecated-${entry.slug}`,
1549
+ className: "shrink-0 rounded-full bg-foreground/10 px-2 py-0.5 text-[11px] text-muted-foreground",
1550
+ children: "No longer available"
1551
+ }
1552
+ )
1553
+ ] }),
1554
+ /* @__PURE__ */ jsxs6("div", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [
1555
+ /* @__PURE__ */ jsx7("span", { "data-testid": `connector-capability-${entry.slug}`, children: capabilityLabel(entry.capability) }),
1556
+ entry.category && /* @__PURE__ */ jsxs6(Fragment3, { children: [
1557
+ /* @__PURE__ */ jsx7("span", { "aria-hidden": "true", children: "\xB7" }),
1558
+ /* @__PURE__ */ jsx7("span", { className: "truncate", children: entry.category })
1559
+ ] })
1560
+ ] }),
1561
+ isConnected && /* @__PURE__ */ jsx7(
1562
+ "p",
1563
+ {
1564
+ "data-testid": `connector-connected-since-${entry.slug}`,
1565
+ className: "text-xs text-muted-foreground",
1566
+ children: entry.connection.since ? `Connected since ${formatSince(entry.connection.since)}` : "Connected"
1567
+ }
1568
+ ),
1569
+ errorMessage && /* @__PURE__ */ jsx7("p", { role: "alert", "data-testid": `connector-error-${entry.slug}`, className: "text-xs text-destructive", children: errorMessage })
1570
+ ] }),
1571
+ isConnected ? /* @__PURE__ */ jsx7(
1572
+ "button",
1573
+ {
1574
+ type: "button",
1575
+ "data-testid": `connector-disconnect-${entry.slug}`,
1576
+ onClick: () => onDisconnect(entry),
1577
+ disabled: isDisconnecting,
1578
+ className: "shrink-0 rounded-md px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50",
1579
+ children: isDisconnecting ? "Disconnecting\u2026" : "Disconnect"
1580
+ }
1581
+ ) : /* @__PURE__ */ jsx7(
1582
+ "button",
1583
+ {
1584
+ type: "button",
1585
+ "data-testid": `connector-connect-${entry.slug}`,
1586
+ onClick: () => onConnect(entry),
1587
+ disabled: isConnecting,
1588
+ className: "shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50",
1589
+ children: isConnecting ? "Connecting\u2026" : "Connect"
1590
+ }
1591
+ )
1592
+ ]
1593
+ }
1594
+ );
1595
+ }
1344
1596
  export {
1345
1597
  AppSidebar,
1346
1598
  BrandGlyph,
1599
+ ConnectorPicker,
1347
1600
  ContextSwitcher,
1348
1601
  Flyout,
1349
1602
  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.0",
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",