diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index a66880e489b..86cc5a4ea1a 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -34,7 +34,7 @@ import { sortPluginEntries, type PluginBrowseShelf, } from "./plugin-browse-discovery"; -import { pluginCatalogCategoryMutedAccentStyle } from "./plugin-ui"; +import { PluginCategoryIcon } from "./plugin-ui"; const SHELF_ENTRY_LIMIT = 6; @@ -215,12 +215,9 @@ export function BrowsePluginsTab({
{description} @@ -209,7 +240,7 @@ export function PluginMarketplaceOverview({ return ( - + {entry.overview === undefined ? null : ( <> @@ -232,11 +263,14 @@ export function PluginMarketplaceListingSections({ <> - - - - - + {entry.marketplace === "bb-official" && + entry.publishedAt === undefined ? null : ( + + + + + + )} > ); } diff --git a/apps/app/src/components/plugin/management/PluginOverviewMarkdown.tsx b/apps/app/src/components/plugin/management/PluginOverviewMarkdown.tsx index 3c67e16e989..cc804d77861 100644 --- a/apps/app/src/components/plugin/management/PluginOverviewMarkdown.tsx +++ b/apps/app/src/components/plugin/management/PluginOverviewMarkdown.tsx @@ -63,7 +63,13 @@ function OverviewHeading({ }) { const Tag = minor ? "h4" : "h3"; return ( - + {children} ); @@ -77,7 +83,7 @@ const OVERVIEW_COMPONENTS: Components = { ), code: ({ children }) => ( - + {children} ), @@ -88,15 +94,23 @@ const OVERVIEW_COMPONENTS: Components = { h5: ({ children }) => {children}, h6: ({ children }) => {children}, hr: () => , - li: ({ children }) => {children}, - ol: ({ children }) => {children}, - p: ({ children }) => {children}, + li: ({ children }) => {children}, + ol: ({ children }) => ( + + {children} + + ), + p: ({ children }) => {children}, pre: ({ children }) => ( {children} ), - ul: ({ children }) => {children}, + ul: ({ children }) => ( + + {children} + + ), }; export function PluginOverviewMarkdown({ markdown }: { markdown: string }) { diff --git a/apps/app/src/components/plugin/management/plugin-ui.test.tsx b/apps/app/src/components/plugin/management/plugin-ui.test.tsx index 5f0671ec651..af6295e828f 100644 --- a/apps/app/src/components/plugin/management/plugin-ui.test.tsx +++ b/apps/app/src/components/plugin/management/plugin-ui.test.tsx @@ -6,7 +6,7 @@ import { PLUGIN_CATALOG_CATEGORIES } from "@bb/domain"; import { CatalogEntryIcon, CatalogEntryIconChip, - pluginCatalogCategoryPillStyle, + pluginCatalogCategoryIconName, pluginInstallCountPresentation, } from "./plugin-ui"; @@ -92,17 +92,13 @@ it("uses one glyph box for host and marketplace catalog icons", () => { } }); -it("uses theme accents for all built-in categories and neutral unknowns", () => { - for (const category of PLUGIN_CATALOG_CATEGORIES) { - const style = pluginCatalogCategoryPillStyle(category.id); - expect(String(style.background)).toContain("color-mix(in oklab"); - expect(String(style.background)).toContain("var(--"); - expect(String(style.background)).not.toContain("var(--ink) 8%"); - } - const unknown = pluginCatalogCategoryPillStyle("future-category"); - expect(unknown.background).toBe( - "color-mix(in oklch, var(--ink) 8%, var(--canvas))", +it("gives every built-in category its own icon and unknowns none", () => { + const icons = PLUGIN_CATALOG_CATEGORIES.map((category) => + pluginCatalogCategoryIconName(category.id), ); + expect(icons).not.toContain(undefined); + expect(new Set(icons).size).toBe(icons.length); + expect(pluginCatalogCategoryIconName("future-category")).toBeUndefined(); }); it("labels install counts only when the catalog knows them", () => { diff --git a/apps/app/src/components/plugin/management/plugin-ui.tsx b/apps/app/src/components/plugin/management/plugin-ui.tsx index 1a07b4393b5..03a3e80e66c 100644 --- a/apps/app/src/components/plugin/management/plugin-ui.tsx +++ b/apps/app/src/components/plugin/management/plugin-ui.tsx @@ -1,5 +1,5 @@ import { PluginBrandIcon } from "@bb/shared-ui/plugin-icon"; -import { useState, type CSSProperties, type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { Icon } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; import { ResourceIconFrame } from "@bb/shared-ui/resource-list"; @@ -61,14 +61,6 @@ function neutral(percent: number): string { return `color-mix(in oklch, var(--ink) ${percent}%, var(--canvas))`; } -function accentTint(token: string, percent: number): string { - return `color-mix(in oklab, var(${token}) ${percent}%, var(--canvas))`; -} - -function accentInk(token: string, percent: number): string { - return `color-mix(in oklab, var(${token}) ${percent}%, var(--ink))`; -} - function pluginCatalogCategoryAccentToken( categoryId: string | undefined, ): string | undefined { @@ -77,31 +69,51 @@ function pluginCatalogCategoryAccentToken( : PLUGIN_CATEGORY_ACCENT_TOKENS[categoryId]; } -export function pluginCatalogCategoryPillStyle( +const PLUGIN_CATEGORY_ICONS: Record = { + "themes-and-appearance": "Palette", + "thread-management": "ListView", + "thread-content": "MessageSquare", + "memory-and-context": "Brain", + security: "Lock", + "agents-and-providers": "Bot", + environments: "Laptop", + "token-usage-and-limits": "ChartColumn", + notifications: "BellDot", + "code-and-reviews": "GitPullRequest", + "file-viewers-and-editors": "FileText", + "cloud-and-remote": "Cloud", + "command-line": "Terminal", + utilities: "Toolbox", + "plugin-development": "Puzzle", + "tasks-and-workflows": "Workflow", +}; + +export function pluginCatalogCategoryIconName( categoryId: string | undefined, -): CSSProperties { - const accentToken = pluginCatalogCategoryAccentToken(categoryId); - return accentToken === undefined - ? { - background: neutral(8), - borderColor: neutral(16), - color: neutral(55), - } - : { - background: accentTint(accentToken, 10), - borderColor: accentTint(accentToken, 18), - color: accentInk(accentToken, 50), - }; +): string | undefined { + return categoryId === undefined ? undefined : PLUGIN_CATEGORY_ICONS[categoryId]; } -export function pluginCatalogCategoryMutedAccentStyle( - categoryId: string | undefined, -): CSSProperties { +export function PluginCategoryIcon({ + categoryId, + className, +}: { + categoryId: string | undefined; + className?: string; +}) { + const iconName = pluginCatalogCategoryIconName(categoryId); const accentToken = pluginCatalogCategoryAccentToken(categoryId); - return { - background: - accentToken === undefined ? neutral(36) : accentTint(accentToken, 55), - }; + if (iconName === undefined || accentToken === undefined) return null; + return ( + + ); } export function PluginLogo({ @@ -175,23 +187,6 @@ export function CatalogEntryIcon({ ); } -export function PluginCategoryLabel({ - categoryId, - label, -}: { - categoryId: string | undefined; - label: string; -}) { - return ( - - {label} - - ); -} - export function CatalogEntryIconChip({ entry, className, diff --git a/apps/app/src/components/tools/PluginDetail.tsx b/apps/app/src/components/tools/PluginDetail.tsx index 8317689d4d9..bc14accd847 100644 --- a/apps/app/src/components/tools/PluginDetail.tsx +++ b/apps/app/src/components/tools/PluginDetail.tsx @@ -1,4 +1,4 @@ -import { PluginCardAuthor } from "@/components/plugin/management/PluginCard"; +import { PluginCardAuthorAvatar } from "@/components/plugin/management/PluginCard"; import { useSyncExternalStore } from "react"; import { ResourceActionButton, @@ -36,7 +36,7 @@ import { import { PluginDetailMetadata, PluginDetailMetadataItem, - PluginMarketplaceCategoryPill, + PluginMarketplaceByline, PluginMarketplaceDetailMetadata, PluginMarketplaceListingSections, PluginMarketplaceOverview, @@ -140,6 +140,12 @@ function PluginLocalSource({ ); } +const OFFICIAL_BYLINE_ENTRY = { + author: null, + marketplace: "bb-official", + publisherLabel: "BB Official", +} as const; + export function CatalogPluginDetail({ entry, onInstall, @@ -158,8 +164,8 @@ export function CatalogPluginDetail({ leading={} leadingClassName="size-6" title={entry.displayName} - titleMeta={} - metadata={} + metadataLeading={} + metadata={} actions={ onDelete(plugin), }, ]; + const bylineEntry = + catalogEntry ?? + (plugin.provenance === "builtin" || + plugin.catalogMarketplaceName === "bb-official" + ? OFFICIAL_BYLINE_ENTRY + : undefined); return ( } title={pluginName} - titleMeta={ - catalogEntry === undefined ? null : ( - + metadataLeading={ + bylineEntry === undefined ? undefined : ( + ) } metadata={ - catalogEntry !== undefined ? ( - - ) : plugin.provenance === "builtin" || - plugin.catalogMarketplaceName === "bb-official" ? ( - - ) : undefined + bylineEntry === undefined ? undefined : ( + + ) } actions={ hasConfiguration ? ( @@ -411,7 +414,7 @@ export function PluginDetail({ > {catalogEntry === undefined ? ( - + { expect(screen.getByRole("heading", { name: "GitHub" })).toBeTruthy(); expect(screen.getAllByText("BB Official").length).toBeGreaterThan(0); - expect(screen.getByText("Developer tools")).toBeTruthy(); + expect(screen.queryByText("Developer tools")).toBeNull(); expect( screen.getByText("Browse GitHub issues and pull requests in BB."), ).toBeTruthy(); @@ -225,6 +225,32 @@ describe("PluginDetail official catalog lifecycle", () => { expect(container.textContent).not.toContain("Last updated"); }); + it("links the category from the byline and drops the duplicate official marketplace", () => { + render( + + undefined} + catalogEntries={[]} + onOpenPlugin={() => undefined} + /> + , + ); + + const link = screen.getByRole("link", { + name: "Browse Code & Reviews plugins", + }); + expect(link.getAttribute("href")).toBe( + "/plugins?shelf=category%3Acode-and-reviews", + ); + expect(screen.queryByText("Marketplace")).toBeNull(); + expect(screen.queryByRole("heading", { name: "Details" })).toBeNull(); + }); + it("explains why an incompatible official plugin cannot be installed", () => { const incompatibleEntry = { ...GITHUB_CATALOG_ENTRY, @@ -286,7 +312,7 @@ describe("PluginDetail official catalog lifecycle", () => { ); expect(screen.getAllByText("BB Official").length).toBeGreaterThan(0); - expect(screen.getByText("Developer tools")).toBeTruthy(); + expect(screen.queryByText("Developer tools")).toBeNull(); expect( screen.queryByRole("button", { name: "Uninstall GitHub" }), ).toBeNull(); diff --git a/packages/shared-ui/src/components/ui/resource/detail-sections.tsx b/packages/shared-ui/src/components/ui/resource/detail-sections.tsx index 69824d62eed..7d83893e1fe 100644 --- a/packages/shared-ui/src/components/ui/resource/detail-sections.tsx +++ b/packages/shared-ui/src/components/ui/resource/detail-sections.tsx @@ -71,6 +71,7 @@ export function ResourceDetailPage({ overflowMenu, actions, metadata, + metadataLeading, maxWidthClassName = "max-w-3xl", children, }: { @@ -82,39 +83,52 @@ export function ResourceDetailPage({ overflowMenu?: ReactNode; actions?: ReactNode; metadata?: ReactNode; + metadataLeading?: ReactNode; maxWidthClassName?: string; children: ReactNode; }) { + const leadingNode = leading ? ( + + {leading} + + ) : null; + const titleRow = ( + + {title} + {titleMeta ? ( + + {titleMeta} + + ) : null} + + ); + const metadataNode = metadata ? ( + {metadata} + ) : null; return ( - - {leading ? ( - - {leading} - - ) : null} - - - - {title} - - {titleMeta ? ( - - {titleMeta} - - ) : null} + {leadingNode && metadataLeading && metadataNode ? ( + + {leadingNode} + {titleRow} + {metadataLeading} + {metadataNode} + + ) : ( + + {leadingNode} + + {titleRow} + {metadataNode} - {metadata ? ( - {metadata} - ) : null} - + )} {actions || lifecycleControl || overflowMenu ? ( {actions}
+ {children} ), @@ -88,15 +94,23 @@ const OVERVIEW_COMPONENTS: Components = { h5: ({ children }) => {children}, h6: ({ children }) => {children}, hr: () => , - li: ({ children }) => {children}, - ol: ({ children }) => {children}, - p: ({ children }) => {children}, + li: ({ children }) => {children}, + ol: ({ children }) => ( + + {children} + + ), + p: ({ children }) => {children}, pre: ({ children }) => ( {children} ), - ul: ({ children }) => {children}, + ul: ({ children }) => ( + + {children} + + ), }; export function PluginOverviewMarkdown({ markdown }: { markdown: string }) { diff --git a/apps/app/src/components/plugin/management/plugin-ui.test.tsx b/apps/app/src/components/plugin/management/plugin-ui.test.tsx index 5f0671ec651..af6295e828f 100644 --- a/apps/app/src/components/plugin/management/plugin-ui.test.tsx +++ b/apps/app/src/components/plugin/management/plugin-ui.test.tsx @@ -6,7 +6,7 @@ import { PLUGIN_CATALOG_CATEGORIES } from "@bb/domain"; import { CatalogEntryIcon, CatalogEntryIconChip, - pluginCatalogCategoryPillStyle, + pluginCatalogCategoryIconName, pluginInstallCountPresentation, } from "./plugin-ui"; @@ -92,17 +92,13 @@ it("uses one glyph box for host and marketplace catalog icons", () => { } }); -it("uses theme accents for all built-in categories and neutral unknowns", () => { - for (const category of PLUGIN_CATALOG_CATEGORIES) { - const style = pluginCatalogCategoryPillStyle(category.id); - expect(String(style.background)).toContain("color-mix(in oklab"); - expect(String(style.background)).toContain("var(--"); - expect(String(style.background)).not.toContain("var(--ink) 8%"); - } - const unknown = pluginCatalogCategoryPillStyle("future-category"); - expect(unknown.background).toBe( - "color-mix(in oklch, var(--ink) 8%, var(--canvas))", +it("gives every built-in category its own icon and unknowns none", () => { + const icons = PLUGIN_CATALOG_CATEGORIES.map((category) => + pluginCatalogCategoryIconName(category.id), ); + expect(icons).not.toContain(undefined); + expect(new Set(icons).size).toBe(icons.length); + expect(pluginCatalogCategoryIconName("future-category")).toBeUndefined(); }); it("labels install counts only when the catalog knows them", () => { diff --git a/apps/app/src/components/plugin/management/plugin-ui.tsx b/apps/app/src/components/plugin/management/plugin-ui.tsx index 1a07b4393b5..03a3e80e66c 100644 --- a/apps/app/src/components/plugin/management/plugin-ui.tsx +++ b/apps/app/src/components/plugin/management/plugin-ui.tsx @@ -1,5 +1,5 @@ import { PluginBrandIcon } from "@bb/shared-ui/plugin-icon"; -import { useState, type CSSProperties, type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { Icon } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; import { ResourceIconFrame } from "@bb/shared-ui/resource-list"; @@ -61,14 +61,6 @@ function neutral(percent: number): string { return `color-mix(in oklch, var(--ink) ${percent}%, var(--canvas))`; } -function accentTint(token: string, percent: number): string { - return `color-mix(in oklab, var(${token}) ${percent}%, var(--canvas))`; -} - -function accentInk(token: string, percent: number): string { - return `color-mix(in oklab, var(${token}) ${percent}%, var(--ink))`; -} - function pluginCatalogCategoryAccentToken( categoryId: string | undefined, ): string | undefined { @@ -77,31 +69,51 @@ function pluginCatalogCategoryAccentToken( : PLUGIN_CATEGORY_ACCENT_TOKENS[categoryId]; } -export function pluginCatalogCategoryPillStyle( +const PLUGIN_CATEGORY_ICONS: Record = { + "themes-and-appearance": "Palette", + "thread-management": "ListView", + "thread-content": "MessageSquare", + "memory-and-context": "Brain", + security: "Lock", + "agents-and-providers": "Bot", + environments: "Laptop", + "token-usage-and-limits": "ChartColumn", + notifications: "BellDot", + "code-and-reviews": "GitPullRequest", + "file-viewers-and-editors": "FileText", + "cloud-and-remote": "Cloud", + "command-line": "Terminal", + utilities: "Toolbox", + "plugin-development": "Puzzle", + "tasks-and-workflows": "Workflow", +}; + +export function pluginCatalogCategoryIconName( categoryId: string | undefined, -): CSSProperties { - const accentToken = pluginCatalogCategoryAccentToken(categoryId); - return accentToken === undefined - ? { - background: neutral(8), - borderColor: neutral(16), - color: neutral(55), - } - : { - background: accentTint(accentToken, 10), - borderColor: accentTint(accentToken, 18), - color: accentInk(accentToken, 50), - }; +): string | undefined { + return categoryId === undefined ? undefined : PLUGIN_CATEGORY_ICONS[categoryId]; } -export function pluginCatalogCategoryMutedAccentStyle( - categoryId: string | undefined, -): CSSProperties { +export function PluginCategoryIcon({ + categoryId, + className, +}: { + categoryId: string | undefined; + className?: string; +}) { + const iconName = pluginCatalogCategoryIconName(categoryId); const accentToken = pluginCatalogCategoryAccentToken(categoryId); - return { - background: - accentToken === undefined ? neutral(36) : accentTint(accentToken, 55), - }; + if (iconName === undefined || accentToken === undefined) return null; + return ( + + ); } export function PluginLogo({ @@ -175,23 +187,6 @@ export function CatalogEntryIcon({ ); } -export function PluginCategoryLabel({ - categoryId, - label, -}: { - categoryId: string | undefined; - label: string; -}) { - return ( - - {label} - - ); -} - export function CatalogEntryIconChip({ entry, className, diff --git a/apps/app/src/components/tools/PluginDetail.tsx b/apps/app/src/components/tools/PluginDetail.tsx index 8317689d4d9..bc14accd847 100644 --- a/apps/app/src/components/tools/PluginDetail.tsx +++ b/apps/app/src/components/tools/PluginDetail.tsx @@ -1,4 +1,4 @@ -import { PluginCardAuthor } from "@/components/plugin/management/PluginCard"; +import { PluginCardAuthorAvatar } from "@/components/plugin/management/PluginCard"; import { useSyncExternalStore } from "react"; import { ResourceActionButton, @@ -36,7 +36,7 @@ import { import { PluginDetailMetadata, PluginDetailMetadataItem, - PluginMarketplaceCategoryPill, + PluginMarketplaceByline, PluginMarketplaceDetailMetadata, PluginMarketplaceListingSections, PluginMarketplaceOverview, @@ -140,6 +140,12 @@ function PluginLocalSource({ ); } +const OFFICIAL_BYLINE_ENTRY = { + author: null, + marketplace: "bb-official", + publisherLabel: "BB Official", +} as const; + export function CatalogPluginDetail({ entry, onInstall, @@ -158,8 +164,8 @@ export function CatalogPluginDetail({ leading={} leadingClassName="size-6" title={entry.displayName} - titleMeta={} - metadata={} + metadataLeading={} + metadata={} actions={ onDelete(plugin), }, ]; + const bylineEntry = + catalogEntry ?? + (plugin.provenance === "builtin" || + plugin.catalogMarketplaceName === "bb-official" + ? OFFICIAL_BYLINE_ENTRY + : undefined); return ( } title={pluginName} - titleMeta={ - catalogEntry === undefined ? null : ( - + metadataLeading={ + bylineEntry === undefined ? undefined : ( + ) } metadata={ - catalogEntry !== undefined ? ( - - ) : plugin.provenance === "builtin" || - plugin.catalogMarketplaceName === "bb-official" ? ( - - ) : undefined + bylineEntry === undefined ? undefined : ( + + ) } actions={ hasConfiguration ? ( @@ -411,7 +414,7 @@ export function PluginDetail({ > {catalogEntry === undefined ? ( - + { expect(screen.getByRole("heading", { name: "GitHub" })).toBeTruthy(); expect(screen.getAllByText("BB Official").length).toBeGreaterThan(0); - expect(screen.getByText("Developer tools")).toBeTruthy(); + expect(screen.queryByText("Developer tools")).toBeNull(); expect( screen.getByText("Browse GitHub issues and pull requests in BB."), ).toBeTruthy(); @@ -225,6 +225,32 @@ describe("PluginDetail official catalog lifecycle", () => { expect(container.textContent).not.toContain("Last updated"); }); + it("links the category from the byline and drops the duplicate official marketplace", () => { + render( + + undefined} + catalogEntries={[]} + onOpenPlugin={() => undefined} + /> + , + ); + + const link = screen.getByRole("link", { + name: "Browse Code & Reviews plugins", + }); + expect(link.getAttribute("href")).toBe( + "/plugins?shelf=category%3Acode-and-reviews", + ); + expect(screen.queryByText("Marketplace")).toBeNull(); + expect(screen.queryByRole("heading", { name: "Details" })).toBeNull(); + }); + it("explains why an incompatible official plugin cannot be installed", () => { const incompatibleEntry = { ...GITHUB_CATALOG_ENTRY, @@ -286,7 +312,7 @@ describe("PluginDetail official catalog lifecycle", () => { ); expect(screen.getAllByText("BB Official").length).toBeGreaterThan(0); - expect(screen.getByText("Developer tools")).toBeTruthy(); + expect(screen.queryByText("Developer tools")).toBeNull(); expect( screen.queryByRole("button", { name: "Uninstall GitHub" }), ).toBeNull(); diff --git a/packages/shared-ui/src/components/ui/resource/detail-sections.tsx b/packages/shared-ui/src/components/ui/resource/detail-sections.tsx index 69824d62eed..7d83893e1fe 100644 --- a/packages/shared-ui/src/components/ui/resource/detail-sections.tsx +++ b/packages/shared-ui/src/components/ui/resource/detail-sections.tsx @@ -71,6 +71,7 @@ export function ResourceDetailPage({ overflowMenu, actions, metadata, + metadataLeading, maxWidthClassName = "max-w-3xl", children, }: { @@ -82,39 +83,52 @@ export function ResourceDetailPage({ overflowMenu?: ReactNode; actions?: ReactNode; metadata?: ReactNode; + metadataLeading?: ReactNode; maxWidthClassName?: string; children: ReactNode; }) { + const leadingNode = leading ? ( + + {leading} + + ) : null; + const titleRow = ( + + {title} + {titleMeta ? ( + + {titleMeta} + + ) : null} + + ); + const metadataNode = metadata ? ( + {metadata} + ) : null; return ( - - {leading ? ( - - {leading} - - ) : null} - - - - {title} - - {titleMeta ? ( - - {titleMeta} - - ) : null} + {leadingNode && metadataLeading && metadataNode ? ( + + {leadingNode} + {titleRow} + {metadataLeading} + {metadataNode} + + ) : ( + + {leadingNode} + + {titleRow} + {metadataNode} - {metadata ? ( - {metadata} - ) : null} - + )} {actions || lifecycleControl || overflowMenu ? ( {actions}
{children}