@burdenoff/website-sdk 2026.716.2 → 2026.719.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.
@@ -22,6 +22,14 @@ interface WebsiteSwitcherProps {
22
22
  /** Optional additional CSS class for the trigger. */
23
23
  className?: string;
24
24
  }
25
+ /**
26
+ * Resolve the logo URL for an ecosystem product.
27
+ *
28
+ * Prefers an explicit `logoUrl`; otherwise derives the canonical marketing-site
29
+ * brand mark (`/logo-icon.png`), which every product website publishes. Callers
30
+ * should fall back to the initial chip if the image fails to load.
31
+ */
32
+ declare function resolveWebsiteProductLogo(product: WebsiteSwitcherProduct): string;
25
33
  declare function WebsiteSwitcher({ products, triggerLabel, searchPlaceholder, emptyText, className, }: WebsiteSwitcherProps): react_jsx_runtime.JSX.Element;
26
34
 
27
- export { WebsiteSwitcher, type WebsiteSwitcherProduct, type WebsiteSwitcherProps };
35
+ export { WebsiteSwitcher, type WebsiteSwitcherProduct, type WebsiteSwitcherProps, resolveWebsiteProductLogo };
@@ -22,6 +22,14 @@ interface WebsiteSwitcherProps {
22
22
  /** Optional additional CSS class for the trigger. */
23
23
  className?: string;
24
24
  }
25
+ /**
26
+ * Resolve the logo URL for an ecosystem product.
27
+ *
28
+ * Prefers an explicit `logoUrl`; otherwise derives the canonical marketing-site
29
+ * brand mark (`/logo-icon.png`), which every product website publishes. Callers
30
+ * should fall back to the initial chip if the image fails to load.
31
+ */
32
+ declare function resolveWebsiteProductLogo(product: WebsiteSwitcherProduct): string;
25
33
  declare function WebsiteSwitcher({ products, triggerLabel, searchPlaceholder, emptyText, className, }: WebsiteSwitcherProps): react_jsx_runtime.JSX.Element;
26
34
 
27
- export { WebsiteSwitcher, type WebsiteSwitcherProduct, type WebsiteSwitcherProps };
35
+ export { WebsiteSwitcher, type WebsiteSwitcherProduct, type WebsiteSwitcherProps, resolveWebsiteProductLogo };
@@ -51,6 +51,10 @@ Input.displayName = "Input";
51
51
  function normalizeSearch(value) {
52
52
  return value.toLowerCase().trim();
53
53
  }
54
+ function resolveWebsiteProductLogo(product) {
55
+ if (product.logoUrl) return product.logoUrl;
56
+ return `${product.websiteUrl.replace(/\/+$/, "")}/logo-icon.png`;
57
+ }
54
58
  function filterProducts(products, query) {
55
59
  const normalized = normalizeSearch(query);
56
60
  if (!normalized) return products;
@@ -65,6 +69,9 @@ function WebsiteSwitcher({
65
69
  }) {
66
70
  const [open, setOpen] = React.useState(false);
67
71
  const [query, setQuery] = React.useState("");
72
+ const [failedLogos, setFailedLogos] = React.useState(
73
+ () => /* @__PURE__ */ new Set()
74
+ );
68
75
  const filtered = React.useMemo(
69
76
  () => filterProducts(products, query),
70
77
  [products, query]
@@ -143,15 +150,21 @@ function WebsiteSwitcher({
143
150
  "text-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
144
151
  ),
145
152
  children: [
146
- product.logoUrl ? /* @__PURE__ */ jsxRuntime.jsx(
153
+ failedLogos.has(product.slug) ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary/10 text-[10px] font-bold text-primary", children: product.name.charAt(0) }) : /* @__PURE__ */ jsxRuntime.jsx(
147
154
  "img",
148
155
  {
149
- src: product.logoUrl,
156
+ src: resolveWebsiteProductLogo(product),
150
157
  alt: "",
151
158
  "aria-hidden": "true",
152
- className: "h-5 w-5 shrink-0 rounded object-contain"
159
+ loading: "lazy",
160
+ className: "h-5 w-5 shrink-0 rounded object-contain",
161
+ onError: () => setFailedLogos((prev) => {
162
+ const next = new Set(prev);
163
+ next.add(product.slug);
164
+ return next;
165
+ })
153
166
  }
154
- ) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary/10 text-[10px] font-bold text-primary", children: product.name.charAt(0) }),
167
+ ),
155
168
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate", children: product.name })
156
169
  ]
157
170
  }
@@ -164,5 +177,6 @@ function WebsiteSwitcher({
164
177
  }
165
178
 
166
179
  exports.WebsiteSwitcher = WebsiteSwitcher;
180
+ exports.resolveWebsiteProductLogo = resolveWebsiteProductLogo;
167
181
  //# sourceMappingURL=website-switcher.js.map
168
182
  //# sourceMappingURL=website-switcher.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/cn.ts","../../src/ui/input.tsx","../../src/components/website-switcher.tsx"],"names":["twMerge","clsx","React","jsx","useState","useMemo","jsxs","DropdownMenu","Globe","ChevronDown","Search"],"mappings":"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","file":"website-switcher.js","sourcesContent":["/**\n * Utility function to merge Tailwind CSS classes\n * Combines clsx and tailwind-merge for optimal class handling\n */\n\nimport { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Merge multiple class names intelligently\n * - Handles conditional classes via clsx\n * - Deduplicates and resolves Tailwind conflicts via tailwind-merge\n *\n * @example\n * ```ts\n * cn('px-4 py-2', 'bg-blue-500', { 'text-white': isActive })\n * // => 'px-4 py-2 bg-blue-500 text-white' (if isActive is true)\n * ```\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","import * as React from \"react\";\n\nimport { cn } from \"../utils/cn\";\n\nconst Input = React.forwardRef<HTMLInputElement, React.ComponentProps<\"input\">>(\n ({ className, type, ...props }, ref) => {\n return (\n <input\n type={type}\n className={cn(\n \"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n className,\n )}\n ref={ref}\n {...props}\n />\n );\n },\n);\nInput.displayName = \"Input\";\n\nexport { Input };\n","\"use client\";\n\n/**\n * WebsiteSwitcher Component\n *\n * A dropdown menu for navigating between Burdenoff ecosystem product websites.\n * Includes a search filter and renders each product with its logo and name.\n *\n * @example\n * ```tsx\n * import { WebsiteSwitcher } from '@burdenoff/website-sdk'\n *\n * <WebsiteSwitcher\n * products={[\n * { slug: 'vibecontrols', name: 'VibeControls', logoUrl: '/vibe.svg', websiteUrl: 'https://vibecontrols.com' },\n * { slug: 'fluidgrids', name: 'FluidGrids', logoUrl: '/fg.svg', websiteUrl: 'https://fluidgrids.com' },\n * ]}\n * />\n * ```\n */\n\nimport { useMemo, useState } from \"react\";\n\nimport * as DropdownMenu from \"@radix-ui/react-dropdown-menu\";\nimport { ChevronDown, Globe, Search } from \"lucide-react\";\n\nimport { Input } from \"../ui/input\";\nimport { cn } from \"../utils/cn\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface WebsiteSwitcherProduct {\n /** Stable identifier for the product. */\n slug: string;\n /** Human-readable product name. */\n name: string;\n /** URL to the product logo (absolute or relative). */\n logoUrl?: string | null;\n /** Product marketing site URL. */\n websiteUrl: string;\n}\n\nexport interface WebsiteSwitcherProps {\n /** All Burdenoff ecosystem products to display. */\n products: WebsiteSwitcherProduct[];\n /** Accessible label for the trigger. */\n triggerLabel?: string;\n /** Placeholder text for the search input. */\n searchPlaceholder?: string;\n /** Empty-state text when filtering returns no results. */\n emptyText?: string;\n /** Optional additional CSS class for the trigger. */\n className?: string;\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction normalizeSearch(value: string): string {\n return value.toLowerCase().trim();\n}\n\nfunction filterProducts(\n products: WebsiteSwitcherProduct[],\n query: string,\n): WebsiteSwitcherProduct[] {\n const normalized = normalizeSearch(query);\n if (!normalized) return products;\n return products.filter((p) => normalizeSearch(p.name).includes(normalized));\n}\n\n// ============================================================================\n// Component\n// ============================================================================\n\nexport function WebsiteSwitcher({\n products,\n triggerLabel = \"Explore products\",\n searchPlaceholder = \"Find a product…\",\n emptyText = \"No products match your search.\",\n className,\n}: WebsiteSwitcherProps) {\n const [open, setOpen] = useState(false);\n const [query, setQuery] = useState(\"\");\n\n const filtered = useMemo(\n () => filterProducts(products, query),\n [products, query],\n );\n\n return (\n <DropdownMenu.Root open={open} onOpenChange={setOpen}>\n <DropdownMenu.Trigger\n className={cn(\n \"inline-flex items-center gap-1.5 text-sm font-medium transition-colors\",\n \"text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring rounded-md\",\n className,\n )}\n aria-label={triggerLabel}\n >\n <Globe className=\"h-4 w-4\" aria-hidden=\"true\" />\n <span className=\"hidden sm:inline\">{triggerLabel}</span>\n <ChevronDown\n className={cn(\n \"h-3.5 w-3.5 transition-transform duration-200\",\n open && \"rotate-180\",\n )}\n aria-hidden=\"true\"\n />\n </DropdownMenu.Trigger>\n\n <DropdownMenu.Portal>\n <DropdownMenu.Content\n className=\"z-50 min-w-[16rem] max-w-[20rem] rounded-lg border border-border/60 bg-popover p-2 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2\"\n sideOffset={8}\n align=\"end\"\n avoidCollisions\n >\n <div className=\"px-2 pb-2 pt-1\">\n <p className=\"text-xs font-medium text-muted-foreground mb-2\">\n Burdenoff ecosystem\n </p>\n <div className=\"relative\">\n <Search\n className=\"absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground\"\n aria-hidden=\"true\"\n />\n <Input\n type=\"search\"\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n placeholder={searchPlaceholder}\n className=\"h-8 pl-8 text-sm\"\n aria-label={searchPlaceholder}\n onKeyDown={(e) => {\n if (e.key === \"Escape\") {\n setQuery(\"\");\n }\n }}\n />\n </div>\n </div>\n\n <DropdownMenu.Separator className=\"my-1 h-px bg-border/60\" />\n\n <div className=\"max-h-[min(60vh,24rem)] overflow-y-auto py-1\">\n {filtered.length === 0 ? (\n <div className=\"px-3 py-4 text-center text-sm text-muted-foreground\">\n {emptyText}\n </div>\n ) : (\n filtered.map((product) => (\n <DropdownMenu.Item key={product.slug} asChild>\n <a\n href={product.websiteUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className={cn(\n \"flex items-center gap-3 rounded-md px-2 py-2 text-sm outline-none\",\n \"text-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground\",\n )}\n >\n {product.logoUrl ? (\n <img\n src={product.logoUrl}\n alt=\"\"\n aria-hidden=\"true\"\n className=\"h-5 w-5 shrink-0 rounded object-contain\"\n />\n ) : (\n <span className=\"flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary/10 text-[10px] font-bold text-primary\">\n {product.name.charAt(0)}\n </span>\n )}\n <span className=\"truncate\">{product.name}</span>\n </a>\n </DropdownMenu.Item>\n ))\n )}\n </div>\n\n <DropdownMenu.Arrow className=\"fill-popover\" />\n </DropdownMenu.Content>\n </DropdownMenu.Portal>\n </DropdownMenu.Root>\n );\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/cn.ts","../../src/ui/input.tsx","../../src/components/website-switcher.tsx"],"names":["twMerge","clsx","React","jsx","useState","useMemo","jsxs","DropdownMenu","Globe","ChevronDown","Search"],"mappings":"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","file":"website-switcher.js","sourcesContent":["/**\n * Utility function to merge Tailwind CSS classes\n * Combines clsx and tailwind-merge for optimal class handling\n */\n\nimport { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Merge multiple class names intelligently\n * - Handles conditional classes via clsx\n * - Deduplicates and resolves Tailwind conflicts via tailwind-merge\n *\n * @example\n * ```ts\n * cn('px-4 py-2', 'bg-blue-500', { 'text-white': isActive })\n * // => 'px-4 py-2 bg-blue-500 text-white' (if isActive is true)\n * ```\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","import * as React from \"react\";\n\nimport { cn } from \"../utils/cn\";\n\nconst Input = React.forwardRef<HTMLInputElement, React.ComponentProps<\"input\">>(\n ({ className, type, ...props }, ref) => {\n return (\n <input\n type={type}\n className={cn(\n \"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n className,\n )}\n ref={ref}\n {...props}\n />\n );\n },\n);\nInput.displayName = \"Input\";\n\nexport { Input };\n","\"use client\";\n\n/**\n * WebsiteSwitcher Component\n *\n * A dropdown menu for navigating between Burdenoff ecosystem product websites.\n * Includes a search filter and renders each product with its logo and name.\n *\n * @example\n * ```tsx\n * import { WebsiteSwitcher } from '@burdenoff/website-sdk'\n *\n * <WebsiteSwitcher\n * products={[\n * { slug: 'vibecontrols', name: 'VibeControls', logoUrl: '/vibe.svg', websiteUrl: 'https://vibecontrols.com' },\n * { slug: 'fluidgrids', name: 'FluidGrids', logoUrl: '/fg.svg', websiteUrl: 'https://fluidgrids.com' },\n * ]}\n * />\n * ```\n */\n\nimport { useMemo, useState } from \"react\";\n\nimport * as DropdownMenu from \"@radix-ui/react-dropdown-menu\";\nimport { ChevronDown, Globe, Search } from \"lucide-react\";\n\nimport { Input } from \"../ui/input\";\nimport { cn } from \"../utils/cn\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface WebsiteSwitcherProduct {\n /** Stable identifier for the product. */\n slug: string;\n /** Human-readable product name. */\n name: string;\n /** URL to the product logo (absolute or relative). */\n logoUrl?: string | null;\n /** Product marketing site URL. */\n websiteUrl: string;\n}\n\nexport interface WebsiteSwitcherProps {\n /** All Burdenoff ecosystem products to display. */\n products: WebsiteSwitcherProduct[];\n /** Accessible label for the trigger. */\n triggerLabel?: string;\n /** Placeholder text for the search input. */\n searchPlaceholder?: string;\n /** Empty-state text when filtering returns no results. */\n emptyText?: string;\n /** Optional additional CSS class for the trigger. */\n className?: string;\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction normalizeSearch(value: string): string {\n return value.toLowerCase().trim();\n}\n\n/**\n * Resolve the logo URL for an ecosystem product.\n *\n * Prefers an explicit `logoUrl`; otherwise derives the canonical marketing-site\n * brand mark (`/logo-icon.png`), which every product website publishes. Callers\n * should fall back to the initial chip if the image fails to load.\n */\nexport function resolveWebsiteProductLogo(\n product: WebsiteSwitcherProduct,\n): string {\n if (product.logoUrl) return product.logoUrl;\n return `${product.websiteUrl.replace(/\\/+$/, \"\")}/logo-icon.png`;\n}\n\nfunction filterProducts(\n products: WebsiteSwitcherProduct[],\n query: string,\n): WebsiteSwitcherProduct[] {\n const normalized = normalizeSearch(query);\n if (!normalized) return products;\n return products.filter((p) => normalizeSearch(p.name).includes(normalized));\n}\n\n// ============================================================================\n// Component\n// ============================================================================\n\nexport function WebsiteSwitcher({\n products,\n triggerLabel = \"Explore products\",\n searchPlaceholder = \"Find a product…\",\n emptyText = \"No products match your search.\",\n className,\n}: WebsiteSwitcherProps) {\n const [open, setOpen] = useState(false);\n const [query, setQuery] = useState(\"\");\n // Slugs whose logo image failed to load — fall back to the initial chip.\n const [failedLogos, setFailedLogos] = useState<ReadonlySet<string>>(\n () => new Set<string>(),\n );\n\n const filtered = useMemo(\n () => filterProducts(products, query),\n [products, query],\n );\n\n return (\n <DropdownMenu.Root open={open} onOpenChange={setOpen}>\n <DropdownMenu.Trigger\n className={cn(\n \"inline-flex items-center gap-1.5 text-sm font-medium transition-colors\",\n \"text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring rounded-md\",\n className,\n )}\n aria-label={triggerLabel}\n >\n <Globe className=\"h-4 w-4\" aria-hidden=\"true\" />\n <span className=\"hidden sm:inline\">{triggerLabel}</span>\n <ChevronDown\n className={cn(\n \"h-3.5 w-3.5 transition-transform duration-200\",\n open && \"rotate-180\",\n )}\n aria-hidden=\"true\"\n />\n </DropdownMenu.Trigger>\n\n <DropdownMenu.Portal>\n <DropdownMenu.Content\n className=\"z-50 min-w-[16rem] max-w-[20rem] rounded-lg border border-border/60 bg-popover p-2 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2\"\n sideOffset={8}\n align=\"end\"\n avoidCollisions\n >\n <div className=\"px-2 pb-2 pt-1\">\n <p className=\"text-xs font-medium text-muted-foreground mb-2\">\n Burdenoff ecosystem\n </p>\n <div className=\"relative\">\n <Search\n className=\"absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground\"\n aria-hidden=\"true\"\n />\n <Input\n type=\"search\"\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n placeholder={searchPlaceholder}\n className=\"h-8 pl-8 text-sm\"\n aria-label={searchPlaceholder}\n onKeyDown={(e) => {\n if (e.key === \"Escape\") {\n setQuery(\"\");\n }\n }}\n />\n </div>\n </div>\n\n <DropdownMenu.Separator className=\"my-1 h-px bg-border/60\" />\n\n <div className=\"max-h-[min(60vh,24rem)] overflow-y-auto py-1\">\n {filtered.length === 0 ? (\n <div className=\"px-3 py-4 text-center text-sm text-muted-foreground\">\n {emptyText}\n </div>\n ) : (\n filtered.map((product) => (\n <DropdownMenu.Item key={product.slug} asChild>\n <a\n href={product.websiteUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className={cn(\n \"flex items-center gap-3 rounded-md px-2 py-2 text-sm outline-none\",\n \"text-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground\",\n )}\n >\n {failedLogos.has(product.slug) ? (\n <span className=\"flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary/10 text-[10px] font-bold text-primary\">\n {product.name.charAt(0)}\n </span>\n ) : (\n <img\n src={resolveWebsiteProductLogo(product)}\n alt=\"\"\n aria-hidden=\"true\"\n loading=\"lazy\"\n className=\"h-5 w-5 shrink-0 rounded object-contain\"\n onError={() =>\n setFailedLogos((prev) => {\n const next = new Set(prev);\n next.add(product.slug);\n return next;\n })\n }\n />\n )}\n <span className=\"truncate\">{product.name}</span>\n </a>\n </DropdownMenu.Item>\n ))\n )}\n </div>\n\n <DropdownMenu.Arrow className=\"fill-popover\" />\n </DropdownMenu.Content>\n </DropdownMenu.Portal>\n </DropdownMenu.Root>\n );\n}\n"]}
@@ -29,6 +29,10 @@ Input.displayName = "Input";
29
29
  function normalizeSearch(value) {
30
30
  return value.toLowerCase().trim();
31
31
  }
32
+ function resolveWebsiteProductLogo(product) {
33
+ if (product.logoUrl) return product.logoUrl;
34
+ return `${product.websiteUrl.replace(/\/+$/, "")}/logo-icon.png`;
35
+ }
32
36
  function filterProducts(products, query) {
33
37
  const normalized = normalizeSearch(query);
34
38
  if (!normalized) return products;
@@ -43,6 +47,9 @@ function WebsiteSwitcher({
43
47
  }) {
44
48
  const [open, setOpen] = useState(false);
45
49
  const [query, setQuery] = useState("");
50
+ const [failedLogos, setFailedLogos] = useState(
51
+ () => /* @__PURE__ */ new Set()
52
+ );
46
53
  const filtered = useMemo(
47
54
  () => filterProducts(products, query),
48
55
  [products, query]
@@ -121,15 +128,21 @@ function WebsiteSwitcher({
121
128
  "text-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
122
129
  ),
123
130
  children: [
124
- product.logoUrl ? /* @__PURE__ */ jsx(
131
+ failedLogos.has(product.slug) ? /* @__PURE__ */ jsx("span", { className: "flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary/10 text-[10px] font-bold text-primary", children: product.name.charAt(0) }) : /* @__PURE__ */ jsx(
125
132
  "img",
126
133
  {
127
- src: product.logoUrl,
134
+ src: resolveWebsiteProductLogo(product),
128
135
  alt: "",
129
136
  "aria-hidden": "true",
130
- className: "h-5 w-5 shrink-0 rounded object-contain"
137
+ loading: "lazy",
138
+ className: "h-5 w-5 shrink-0 rounded object-contain",
139
+ onError: () => setFailedLogos((prev) => {
140
+ const next = new Set(prev);
141
+ next.add(product.slug);
142
+ return next;
143
+ })
131
144
  }
132
- ) : /* @__PURE__ */ jsx("span", { className: "flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary/10 text-[10px] font-bold text-primary", children: product.name.charAt(0) }),
145
+ ),
133
146
  /* @__PURE__ */ jsx("span", { className: "truncate", children: product.name })
134
147
  ]
135
148
  }
@@ -141,6 +154,6 @@ function WebsiteSwitcher({
141
154
  ] });
142
155
  }
143
156
 
144
- export { WebsiteSwitcher };
157
+ export { WebsiteSwitcher, resolveWebsiteProductLogo };
145
158
  //# sourceMappingURL=website-switcher.mjs.map
146
159
  //# sourceMappingURL=website-switcher.mjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/cn.ts","../../src/ui/input.tsx","../../src/components/website-switcher.tsx"],"names":["jsx"],"mappings":"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","file":"website-switcher.mjs","sourcesContent":["/**\n * Utility function to merge Tailwind CSS classes\n * Combines clsx and tailwind-merge for optimal class handling\n */\n\nimport { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Merge multiple class names intelligently\n * - Handles conditional classes via clsx\n * - Deduplicates and resolves Tailwind conflicts via tailwind-merge\n *\n * @example\n * ```ts\n * cn('px-4 py-2', 'bg-blue-500', { 'text-white': isActive })\n * // => 'px-4 py-2 bg-blue-500 text-white' (if isActive is true)\n * ```\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","import * as React from \"react\";\n\nimport { cn } from \"../utils/cn\";\n\nconst Input = React.forwardRef<HTMLInputElement, React.ComponentProps<\"input\">>(\n ({ className, type, ...props }, ref) => {\n return (\n <input\n type={type}\n className={cn(\n \"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n className,\n )}\n ref={ref}\n {...props}\n />\n );\n },\n);\nInput.displayName = \"Input\";\n\nexport { Input };\n","\"use client\";\n\n/**\n * WebsiteSwitcher Component\n *\n * A dropdown menu for navigating between Burdenoff ecosystem product websites.\n * Includes a search filter and renders each product with its logo and name.\n *\n * @example\n * ```tsx\n * import { WebsiteSwitcher } from '@burdenoff/website-sdk'\n *\n * <WebsiteSwitcher\n * products={[\n * { slug: 'vibecontrols', name: 'VibeControls', logoUrl: '/vibe.svg', websiteUrl: 'https://vibecontrols.com' },\n * { slug: 'fluidgrids', name: 'FluidGrids', logoUrl: '/fg.svg', websiteUrl: 'https://fluidgrids.com' },\n * ]}\n * />\n * ```\n */\n\nimport { useMemo, useState } from \"react\";\n\nimport * as DropdownMenu from \"@radix-ui/react-dropdown-menu\";\nimport { ChevronDown, Globe, Search } from \"lucide-react\";\n\nimport { Input } from \"../ui/input\";\nimport { cn } from \"../utils/cn\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface WebsiteSwitcherProduct {\n /** Stable identifier for the product. */\n slug: string;\n /** Human-readable product name. */\n name: string;\n /** URL to the product logo (absolute or relative). */\n logoUrl?: string | null;\n /** Product marketing site URL. */\n websiteUrl: string;\n}\n\nexport interface WebsiteSwitcherProps {\n /** All Burdenoff ecosystem products to display. */\n products: WebsiteSwitcherProduct[];\n /** Accessible label for the trigger. */\n triggerLabel?: string;\n /** Placeholder text for the search input. */\n searchPlaceholder?: string;\n /** Empty-state text when filtering returns no results. */\n emptyText?: string;\n /** Optional additional CSS class for the trigger. */\n className?: string;\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction normalizeSearch(value: string): string {\n return value.toLowerCase().trim();\n}\n\nfunction filterProducts(\n products: WebsiteSwitcherProduct[],\n query: string,\n): WebsiteSwitcherProduct[] {\n const normalized = normalizeSearch(query);\n if (!normalized) return products;\n return products.filter((p) => normalizeSearch(p.name).includes(normalized));\n}\n\n// ============================================================================\n// Component\n// ============================================================================\n\nexport function WebsiteSwitcher({\n products,\n triggerLabel = \"Explore products\",\n searchPlaceholder = \"Find a product…\",\n emptyText = \"No products match your search.\",\n className,\n}: WebsiteSwitcherProps) {\n const [open, setOpen] = useState(false);\n const [query, setQuery] = useState(\"\");\n\n const filtered = useMemo(\n () => filterProducts(products, query),\n [products, query],\n );\n\n return (\n <DropdownMenu.Root open={open} onOpenChange={setOpen}>\n <DropdownMenu.Trigger\n className={cn(\n \"inline-flex items-center gap-1.5 text-sm font-medium transition-colors\",\n \"text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring rounded-md\",\n className,\n )}\n aria-label={triggerLabel}\n >\n <Globe className=\"h-4 w-4\" aria-hidden=\"true\" />\n <span className=\"hidden sm:inline\">{triggerLabel}</span>\n <ChevronDown\n className={cn(\n \"h-3.5 w-3.5 transition-transform duration-200\",\n open && \"rotate-180\",\n )}\n aria-hidden=\"true\"\n />\n </DropdownMenu.Trigger>\n\n <DropdownMenu.Portal>\n <DropdownMenu.Content\n className=\"z-50 min-w-[16rem] max-w-[20rem] rounded-lg border border-border/60 bg-popover p-2 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2\"\n sideOffset={8}\n align=\"end\"\n avoidCollisions\n >\n <div className=\"px-2 pb-2 pt-1\">\n <p className=\"text-xs font-medium text-muted-foreground mb-2\">\n Burdenoff ecosystem\n </p>\n <div className=\"relative\">\n <Search\n className=\"absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground\"\n aria-hidden=\"true\"\n />\n <Input\n type=\"search\"\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n placeholder={searchPlaceholder}\n className=\"h-8 pl-8 text-sm\"\n aria-label={searchPlaceholder}\n onKeyDown={(e) => {\n if (e.key === \"Escape\") {\n setQuery(\"\");\n }\n }}\n />\n </div>\n </div>\n\n <DropdownMenu.Separator className=\"my-1 h-px bg-border/60\" />\n\n <div className=\"max-h-[min(60vh,24rem)] overflow-y-auto py-1\">\n {filtered.length === 0 ? (\n <div className=\"px-3 py-4 text-center text-sm text-muted-foreground\">\n {emptyText}\n </div>\n ) : (\n filtered.map((product) => (\n <DropdownMenu.Item key={product.slug} asChild>\n <a\n href={product.websiteUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className={cn(\n \"flex items-center gap-3 rounded-md px-2 py-2 text-sm outline-none\",\n \"text-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground\",\n )}\n >\n {product.logoUrl ? (\n <img\n src={product.logoUrl}\n alt=\"\"\n aria-hidden=\"true\"\n className=\"h-5 w-5 shrink-0 rounded object-contain\"\n />\n ) : (\n <span className=\"flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary/10 text-[10px] font-bold text-primary\">\n {product.name.charAt(0)}\n </span>\n )}\n <span className=\"truncate\">{product.name}</span>\n </a>\n </DropdownMenu.Item>\n ))\n )}\n </div>\n\n <DropdownMenu.Arrow className=\"fill-popover\" />\n </DropdownMenu.Content>\n </DropdownMenu.Portal>\n </DropdownMenu.Root>\n );\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/cn.ts","../../src/ui/input.tsx","../../src/components/website-switcher.tsx"],"names":["jsx"],"mappings":"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","file":"website-switcher.mjs","sourcesContent":["/**\n * Utility function to merge Tailwind CSS classes\n * Combines clsx and tailwind-merge for optimal class handling\n */\n\nimport { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Merge multiple class names intelligently\n * - Handles conditional classes via clsx\n * - Deduplicates and resolves Tailwind conflicts via tailwind-merge\n *\n * @example\n * ```ts\n * cn('px-4 py-2', 'bg-blue-500', { 'text-white': isActive })\n * // => 'px-4 py-2 bg-blue-500 text-white' (if isActive is true)\n * ```\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","import * as React from \"react\";\n\nimport { cn } from \"../utils/cn\";\n\nconst Input = React.forwardRef<HTMLInputElement, React.ComponentProps<\"input\">>(\n ({ className, type, ...props }, ref) => {\n return (\n <input\n type={type}\n className={cn(\n \"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n className,\n )}\n ref={ref}\n {...props}\n />\n );\n },\n);\nInput.displayName = \"Input\";\n\nexport { Input };\n","\"use client\";\n\n/**\n * WebsiteSwitcher Component\n *\n * A dropdown menu for navigating between Burdenoff ecosystem product websites.\n * Includes a search filter and renders each product with its logo and name.\n *\n * @example\n * ```tsx\n * import { WebsiteSwitcher } from '@burdenoff/website-sdk'\n *\n * <WebsiteSwitcher\n * products={[\n * { slug: 'vibecontrols', name: 'VibeControls', logoUrl: '/vibe.svg', websiteUrl: 'https://vibecontrols.com' },\n * { slug: 'fluidgrids', name: 'FluidGrids', logoUrl: '/fg.svg', websiteUrl: 'https://fluidgrids.com' },\n * ]}\n * />\n * ```\n */\n\nimport { useMemo, useState } from \"react\";\n\nimport * as DropdownMenu from \"@radix-ui/react-dropdown-menu\";\nimport { ChevronDown, Globe, Search } from \"lucide-react\";\n\nimport { Input } from \"../ui/input\";\nimport { cn } from \"../utils/cn\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface WebsiteSwitcherProduct {\n /** Stable identifier for the product. */\n slug: string;\n /** Human-readable product name. */\n name: string;\n /** URL to the product logo (absolute or relative). */\n logoUrl?: string | null;\n /** Product marketing site URL. */\n websiteUrl: string;\n}\n\nexport interface WebsiteSwitcherProps {\n /** All Burdenoff ecosystem products to display. */\n products: WebsiteSwitcherProduct[];\n /** Accessible label for the trigger. */\n triggerLabel?: string;\n /** Placeholder text for the search input. */\n searchPlaceholder?: string;\n /** Empty-state text when filtering returns no results. */\n emptyText?: string;\n /** Optional additional CSS class for the trigger. */\n className?: string;\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction normalizeSearch(value: string): string {\n return value.toLowerCase().trim();\n}\n\n/**\n * Resolve the logo URL for an ecosystem product.\n *\n * Prefers an explicit `logoUrl`; otherwise derives the canonical marketing-site\n * brand mark (`/logo-icon.png`), which every product website publishes. Callers\n * should fall back to the initial chip if the image fails to load.\n */\nexport function resolveWebsiteProductLogo(\n product: WebsiteSwitcherProduct,\n): string {\n if (product.logoUrl) return product.logoUrl;\n return `${product.websiteUrl.replace(/\\/+$/, \"\")}/logo-icon.png`;\n}\n\nfunction filterProducts(\n products: WebsiteSwitcherProduct[],\n query: string,\n): WebsiteSwitcherProduct[] {\n const normalized = normalizeSearch(query);\n if (!normalized) return products;\n return products.filter((p) => normalizeSearch(p.name).includes(normalized));\n}\n\n// ============================================================================\n// Component\n// ============================================================================\n\nexport function WebsiteSwitcher({\n products,\n triggerLabel = \"Explore products\",\n searchPlaceholder = \"Find a product…\",\n emptyText = \"No products match your search.\",\n className,\n}: WebsiteSwitcherProps) {\n const [open, setOpen] = useState(false);\n const [query, setQuery] = useState(\"\");\n // Slugs whose logo image failed to load — fall back to the initial chip.\n const [failedLogos, setFailedLogos] = useState<ReadonlySet<string>>(\n () => new Set<string>(),\n );\n\n const filtered = useMemo(\n () => filterProducts(products, query),\n [products, query],\n );\n\n return (\n <DropdownMenu.Root open={open} onOpenChange={setOpen}>\n <DropdownMenu.Trigger\n className={cn(\n \"inline-flex items-center gap-1.5 text-sm font-medium transition-colors\",\n \"text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring rounded-md\",\n className,\n )}\n aria-label={triggerLabel}\n >\n <Globe className=\"h-4 w-4\" aria-hidden=\"true\" />\n <span className=\"hidden sm:inline\">{triggerLabel}</span>\n <ChevronDown\n className={cn(\n \"h-3.5 w-3.5 transition-transform duration-200\",\n open && \"rotate-180\",\n )}\n aria-hidden=\"true\"\n />\n </DropdownMenu.Trigger>\n\n <DropdownMenu.Portal>\n <DropdownMenu.Content\n className=\"z-50 min-w-[16rem] max-w-[20rem] rounded-lg border border-border/60 bg-popover p-2 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2\"\n sideOffset={8}\n align=\"end\"\n avoidCollisions\n >\n <div className=\"px-2 pb-2 pt-1\">\n <p className=\"text-xs font-medium text-muted-foreground mb-2\">\n Burdenoff ecosystem\n </p>\n <div className=\"relative\">\n <Search\n className=\"absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground\"\n aria-hidden=\"true\"\n />\n <Input\n type=\"search\"\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n placeholder={searchPlaceholder}\n className=\"h-8 pl-8 text-sm\"\n aria-label={searchPlaceholder}\n onKeyDown={(e) => {\n if (e.key === \"Escape\") {\n setQuery(\"\");\n }\n }}\n />\n </div>\n </div>\n\n <DropdownMenu.Separator className=\"my-1 h-px bg-border/60\" />\n\n <div className=\"max-h-[min(60vh,24rem)] overflow-y-auto py-1\">\n {filtered.length === 0 ? (\n <div className=\"px-3 py-4 text-center text-sm text-muted-foreground\">\n {emptyText}\n </div>\n ) : (\n filtered.map((product) => (\n <DropdownMenu.Item key={product.slug} asChild>\n <a\n href={product.websiteUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className={cn(\n \"flex items-center gap-3 rounded-md px-2 py-2 text-sm outline-none\",\n \"text-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground\",\n )}\n >\n {failedLogos.has(product.slug) ? (\n <span className=\"flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary/10 text-[10px] font-bold text-primary\">\n {product.name.charAt(0)}\n </span>\n ) : (\n <img\n src={resolveWebsiteProductLogo(product)}\n alt=\"\"\n aria-hidden=\"true\"\n loading=\"lazy\"\n className=\"h-5 w-5 shrink-0 rounded object-contain\"\n onError={() =>\n setFailedLogos((prev) => {\n const next = new Set(prev);\n next.add(product.slug);\n return next;\n })\n }\n />\n )}\n <span className=\"truncate\">{product.name}</span>\n </a>\n </DropdownMenu.Item>\n ))\n )}\n </div>\n\n <DropdownMenu.Arrow className=\"fill-popover\" />\n </DropdownMenu.Content>\n </DropdownMenu.Portal>\n </DropdownMenu.Root>\n );\n}\n"]}
package/dist/index.d.mts CHANGED
@@ -55,7 +55,7 @@ declare function CareersApplyForm({ productName, recaptchaSiteKey, positions, he
55
55
 
56
56
  declare const buttonVariants: (props?: ({
57
57
  variant?: "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | undefined;
58
- size?: "icon" | "default" | "sm" | "lg" | null | undefined;
58
+ size?: "default" | "sm" | "lg" | "icon" | null | undefined;
59
59
  } & class_variance_authority_types.ClassProp) | undefined) => string;
60
60
  interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
61
61
  asChild?: boolean;
package/dist/index.d.ts CHANGED
@@ -55,7 +55,7 @@ declare function CareersApplyForm({ productName, recaptchaSiteKey, positions, he
55
55
 
56
56
  declare const buttonVariants: (props?: ({
57
57
  variant?: "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | undefined;
58
- size?: "icon" | "default" | "sm" | "lg" | null | undefined;
58
+ size?: "default" | "sm" | "lg" | "icon" | null | undefined;
59
59
  } & class_variance_authority_types.ClassProp) | undefined) => string;
60
60
  interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
61
61
  asChild?: boolean;
package/dist/index.js CHANGED
@@ -3943,6 +3943,10 @@ function ResourceLinks({
3943
3943
  function normalizeSearch(value) {
3944
3944
  return value.toLowerCase().trim();
3945
3945
  }
3946
+ function resolveWebsiteProductLogo(product) {
3947
+ if (product.logoUrl) return product.logoUrl;
3948
+ return `${product.websiteUrl.replace(/\/+$/, "")}/logo-icon.png`;
3949
+ }
3946
3950
  function filterProducts(products, query) {
3947
3951
  const normalized = normalizeSearch(query);
3948
3952
  if (!normalized) return products;
@@ -3957,6 +3961,9 @@ function WebsiteSwitcher({
3957
3961
  }) {
3958
3962
  const [open, setOpen] = React.useState(false);
3959
3963
  const [query, setQuery] = React.useState("");
3964
+ const [failedLogos, setFailedLogos] = React.useState(
3965
+ () => /* @__PURE__ */ new Set()
3966
+ );
3960
3967
  const filtered = React.useMemo(
3961
3968
  () => filterProducts(products, query),
3962
3969
  [products, query]
@@ -4035,15 +4042,21 @@ function WebsiteSwitcher({
4035
4042
  "text-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
4036
4043
  ),
4037
4044
  children: [
4038
- product.logoUrl ? /* @__PURE__ */ jsxRuntime.jsx(
4045
+ failedLogos.has(product.slug) ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary/10 text-[10px] font-bold text-primary", children: product.name.charAt(0) }) : /* @__PURE__ */ jsxRuntime.jsx(
4039
4046
  "img",
4040
4047
  {
4041
- src: product.logoUrl,
4048
+ src: resolveWebsiteProductLogo(product),
4042
4049
  alt: "",
4043
4050
  "aria-hidden": "true",
4044
- className: "h-5 w-5 shrink-0 rounded object-contain"
4051
+ loading: "lazy",
4052
+ className: "h-5 w-5 shrink-0 rounded object-contain",
4053
+ onError: () => setFailedLogos((prev) => {
4054
+ const next = new Set(prev);
4055
+ next.add(product.slug);
4056
+ return next;
4057
+ })
4045
4058
  }
4046
- ) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary/10 text-[10px] font-bold text-primary", children: product.name.charAt(0) }),
4059
+ ),
4047
4060
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate", children: product.name })
4048
4061
  ]
4049
4062
  }