diff --git a/proof/skill-hero-creator-pr/plugin-detail.png b/proof/skill-hero-creator-pr/plugin-detail.png new file mode 100644 index 00000000..23fcec1a Binary files /dev/null and b/proof/skill-hero-creator-pr/plugin-detail.png differ diff --git a/proof/skill-hero-creator-pr/skill-detail.png b/proof/skill-hero-creator-pr/skill-detail.png new file mode 100644 index 00000000..a9eaf94f Binary files /dev/null and b/proof/skill-hero-creator-pr/skill-detail.png differ diff --git a/src/__tests__/package-detail-route.test.tsx b/src/__tests__/package-detail-route.test.tsx index b8fe19ad..00ae504e 100644 --- a/src/__tests__/package-detail-route.test.tsx +++ b/src/__tests__/package-detail-route.test.tsx @@ -970,10 +970,11 @@ describe("plugin detail route", () => { const route = await loadRoute(); const Component = route.__config.component as ComponentType; - render(); + const { container } = render(); - expect(screen.getAllByText("Official").length).toBeGreaterThan(0); expect(screen.getAllByLabelText("Official").length).toBeGreaterThan(0); + expect(container.querySelector(".skill-hero-creator .official-badge-icon-only")).toBeTruthy(); + expect(container.querySelector(".skill-hero-title-row .official-tag")).toBeNull(); expect(screen.queryByText("Verified")).toBeNull(); }); @@ -1106,7 +1107,8 @@ describe("plugin detail route", () => { const typeRow = sidebarRows.find((row) => row.text.includes("Code Plugin")); expect(downloadsRow?.hasDownload).toBe(false); expect(downloadOnlyRow).toBeTruthy(); - expect(creatorRow).toBeTruthy(); + expect(creatorRow).toBeUndefined(); + expect(document.querySelector(".skill-hero-creator")?.textContent).toContain("Demo Owner"); expect(typeRow).toBeTruthy(); expect(sidebarRows.at(-1)).toEqual(downloadOnlyRow); expect(screen.getByRole("link", { name: /Download/i }).getAttribute("href")).toBe( diff --git a/src/__tests__/skill-detail-page.test.tsx b/src/__tests__/skill-detail-page.test.tsx index daaf60ff..0febdca3 100644 --- a/src/__tests__/skill-detail-page.test.tsx +++ b/src/__tests__/skill-detail-page.test.tsx @@ -1128,7 +1128,16 @@ describe("SkillDetailPage", () => { const securityAuditLabelIndex = sidebarLabels.findIndex((label) => label?.startsWith("Security audit"), ); - expect(securityAuditLabelIndex).toBe(sidebarLabels.indexOf("Creator") + 1); + expect(sidebarLabels).not.toContain("Creator"); + expect(securityAuditLabelIndex).toBeGreaterThanOrEqual(0); + const heroCreator = document.querySelector(".skill-hero-creator"); + expect(heroCreator).toBeTruthy(); + const summary = document.querySelector(".skill-summary-block"); + expect( + summary && + heroCreator && + summary.compareDocumentPosition(heroCreator) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); expect( screen.getByRole("button", { name: "Security checks across malware telemetry and agentic risk", diff --git a/src/components/DetailPageShell.tsx b/src/components/DetailPageShell.tsx index c48851d9..a28030be 100644 --- a/src/components/DetailPageShell.tsx +++ b/src/components/DetailPageShell.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from "react"; import { cn } from "../lib/utils"; +export const DETAIL_HERO_TOPIC_LIMIT = 4; + type DetailPageShellProps = { children: ReactNode; className?: string; diff --git a/src/components/OfficialBadge.tsx b/src/components/OfficialBadge.tsx index 9d487575..43c39453 100644 --- a/src/components/OfficialBadge.tsx +++ b/src/components/OfficialBadge.tsx @@ -14,14 +14,27 @@ export function OfficialTag({ className }: { className?: string }) { ); } -export function OfficialBadge({ className }: { className?: string }) { +type OfficialBadgeProps = { + className?: string; + iconOnly?: boolean; + size?: number; +}; + +export function OfficialBadge({ className, iconOnly = false, size = 12 }: OfficialBadgeProps) { + if (iconOnly) { + const iconClassName = className + ? `official-badge-icon-only ${className}` + : "official-badge-icon-only"; + return ; + } + return ( - ); } diff --git a/src/components/SkillHeader.test.tsx b/src/components/SkillHeader.test.tsx index c1bdc335..c140dfca 100644 --- a/src/components/SkillHeader.test.tsx +++ b/src/components/SkillHeader.test.tsx @@ -27,6 +27,10 @@ vi.mock("@tanstack/react-router", () => ({ }, })); +vi.mock("../lib/useHeroCreatorPublisher", () => ({ + useHeroCreatorPublisher: ({ owner }: { owner?: PublicPublisher | null }) => owner, +})); + describe("SkillHeader", () => { function sidebarStatsRoot(container: HTMLElement) { const node = container.querySelector(".detail-sidebar-stats"); @@ -34,6 +38,22 @@ describe("SkillHeader", () => { return node as HTMLElement; } + function heroCreatorRoot(container: HTMLElement) { + const node = container.querySelector(".skill-hero-creator"); + if (!node) throw new Error("Missing .skill-hero-creator"); + return node as HTMLElement; + } + + function expectCreatorBelowSummary(container: HTMLElement) { + const summary = container.querySelector(".skill-summary-block"); + const creator = container.querySelector(".skill-hero-creator"); + expect(summary).toBeTruthy(); + expect(creator).toBeTruthy(); + expect( + summary!.compareDocumentPosition(creator!) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + } + function setViewportWidth(width: number) { vi.stubGlobal("matchMedia", (query: string) => { const minWidth = /\(min-width:\s*(\d+)px\)/.exec(query)?.[1]; @@ -156,7 +176,10 @@ describe("SkillHeader", () => { expect(onRequireSignIn).toHaveBeenCalledTimes(2); expect(onToggleStar).not.toHaveBeenCalled(); expect(onOpenReport).not.toHaveBeenCalled(); - expect(within(sidebarStatsRoot(container)).getByText("Creator")).toBeTruthy(); + expectCreatorBelowSummary(container); + expect(within(heroCreatorRoot(container)).getByText("Local")).toBeTruthy(); + expect(within(heroCreatorRoot(container)).getByText("@local")).toBeTruthy(); + expect(within(sidebarStatsRoot(container)).queryByText("Creator")).toBeNull(); expect(within(sidebarStatsRoot(container)).getByText("Downloads")).toBeTruthy(); expect(within(sidebarStatsRoot(container)).getByText("2")).toBeTruthy(); expect(container.querySelector('a[href="/local"]')).toBeTruthy(); @@ -184,18 +207,22 @@ describe("SkillHeader", () => { it("keeps desktop-width sidebar details expanded at 1071px", () => { const { container } = renderHeader(); - expect(within(sidebarStatsRoot(container)).getByText("Creator")).toBeTruthy(); + expectCreatorBelowSummary(container); + expect(within(heroCreatorRoot(container)).getByText("Local")).toBeTruthy(); + expect(within(sidebarStatsRoot(container)).queryByText("Creator")).toBeNull(); expect(within(sidebarStatsRoot(container)).getByText("Downloads")).toBeTruthy(); expect(container.querySelector(".detail-mobile-master-tab-list")).toBeTruthy(); }); - it("uses mobile master tabs and creator placement below the title below 901px", () => { + it("places creator below the summary and keeps it out of the sidebar below 901px", () => { setViewportWidth(488); const { container } = renderHeader(); - const creator = container.querySelector(".skill-hero-mobile-creator"); + const creator = container.querySelector(".skill-hero-creator"); const statsPanel = container.querySelector("#skill-mobile-master-panel-stats"); + expectCreatorBelowSummary(container); expect(creator?.textContent).toContain("Local"); + expect(creator?.textContent).toContain("@local"); expect(statsPanel?.textContent).not.toContain("Creator"); expect(statsPanel?.hasAttribute("hidden")).toBe(true); @@ -330,7 +357,7 @@ describe("SkillHeader", () => { expect(container.querySelectorAll(".metric-trend-marker-line")).toHaveLength(1); }); - it("shows the Official tag in the title for official owner skills", () => { + it("shows the Official badge on the creator for official publishers", () => { const { container } = renderHeader({ owner: { ...owner, @@ -338,8 +365,9 @@ describe("SkillHeader", () => { }, }); - expect(screen.getByText("Official")).toBeTruthy(); - expect(container.querySelector(".official-tag")).toBeTruthy(); + const creator = container.querySelector(".skill-hero-creator"); + expect(creator?.querySelector(".user-name-row .official-badge-icon-only")).toBeTruthy(); + expect(container.querySelector(".skill-hero-title-row .official-tag")).toBeNull(); }); it("renders canonical topics in the detail hero", () => { @@ -388,7 +416,8 @@ describe("SkillHeader", () => { const { container } = renderHeader({ showArchiveMetadata: false }); expect(within(sidebarStatsRoot(container)).getByText("Downloads")).toBeTruthy(); - expect(within(sidebarStatsRoot(container)).getByText("Creator")).toBeTruthy(); + expect(within(sidebarStatsRoot(container)).queryByText("Creator")).toBeNull(); + expect(within(heroCreatorRoot(container)).getByText("Local")).toBeTruthy(); expect(within(sidebarStatsRoot(container)).getByText("Last updated")).toBeTruthy(); expect(screen.queryByText("Current version")).toBeNull(); expect(screen.queryByText("License")).toBeNull(); diff --git a/src/components/SkillHeader.tsx b/src/components/SkillHeader.tsx index 5b34b5c3..549fc024 100644 --- a/src/components/SkillHeader.tsx +++ b/src/components/SkillHeader.tsx @@ -5,18 +5,18 @@ import { Flag, Settings, ShieldCheck, Star, Upload } from "lucide-react"; import { useState, type ReactNode } from "react"; import type { Doc, Id } from "../../convex/_generated/dataModel"; import type { ActivityTrend } from "../lib/activityTrend"; -import { getSkillBadges } from "../lib/badges"; +import { getSkillBadges, isSkillOfficial } from "../lib/badges"; import { BrowseCategoryIcon } from "../lib/browseCategoryIcons"; import { buildSkillCategoryBrowseHref, type SkillCategory } from "../lib/categories"; import { formatSkillStatsTriplet } from "../lib/numberFormat"; import { buildPublisherProfileHref } from "../lib/ownerRoute"; import type { PublicPublisher, PublicSkill } from "../lib/publicUser"; import { timeAgo } from "../lib/timeAgo"; +import { useHeroCreatorPublisher } from "../lib/useHeroCreatorPublisher"; import { ActivityMetricLabel } from "./ActivityMetricLabel"; -import { DetailHero } from "./DetailPageShell"; +import { DetailHero, DETAIL_HERO_TOPIC_LIMIT } from "./DetailPageShell"; import { DetailSecuritySummaryLabel } from "./DetailSecuritySummary"; import { useDownloadsSidebarMetricBlock } from "./DownloadsMetricCard"; -import { OfficialTag } from "./OfficialBadge"; import { SidebarMetadata } from "./SidebarMetadata"; import { buildSkillHref } from "./skillDetailUtils"; import { SkillCommandLineCard } from "./SkillInstallSurface"; @@ -48,7 +48,6 @@ type SkillCanonical = { owner: { handle: string | null; userId: Id<"users"> | null }; }; -const MAX_HEADER_TOPICS = 5; const SUMMARY_COLLAPSE_THRESHOLD = 220; type MobileDetailPanel = "content" | "stats"; @@ -173,8 +172,11 @@ export function SkillHeader({ const hasOwnerActions = Boolean(newVersionHref) || Boolean(settingsHref); const showReportAction = !canManage || isStaff; const badges = getSkillBadges(skill); - const isOfficial = badges.includes("Official") || owner?.official === true; const titleBadges = badges.filter((badge) => badge !== "Official"); + const heroCreatorPublisher = useHeroCreatorPublisher({ + owner, + skillOfficial: isSkillOfficial(skill), + }); const showHeroMeta = Boolean((forkOf && forkOfHref) || canonicalHref); const showTitleBadges = titleBadges.length > 0; const headerDescription = @@ -182,7 +184,7 @@ export function SkillHeader({ const headerTopics = (skill.topics ?? []) .map((topic) => topic.trim()) .filter(Boolean) - .slice(0, MAX_HEADER_TOPICS); + .slice(0, DETAIL_HERO_TOPIC_LIMIT); const headerCategories = (categories ?? (category ? [category] : [])).slice(0, 3); const hasSummaryToggle = headerDescription.length > SUMMARY_COLLAPSE_THRESHOLD; const [isSummaryExpanded, setIsSummaryExpanded] = useState(false); @@ -281,6 +283,7 @@ export function SkillHeader({ securityAuditSummary={securityAuditSummary} activityTrend={activityTrend} activityTrendLoading={activityTrendLoading} + hideCreator /> {renderSidebarActions()} @@ -401,7 +404,6 @@ export function SkillHeader({ ) : null}

{skill.displayName}

- {isOfficial ? : null} {showTitleBadges ? (
{titleBadges.map((badge) => ( @@ -413,18 +415,29 @@ export function SkillHeader({ ) : null} {nixPlugin ? Plugin bundle (nix) : null}
- {owner || ownerHandle ? ( -
- + {showHeroMeta ? ( +
+ {forkOf && forkOfHref ? ( + + {forkOfLabel} + + {forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""} + {forkOf.skill.slug} + + {forkOf.version ? ( + ({forkOf.version}) + ) : null} + + ) : null} + {canonicalHref ? ( + + canonical + + {canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""} + {canonical?.skill?.slug} + + + ) : null}
) : null}
@@ -447,41 +460,27 @@ export function SkillHeader({ ) : null}
+ {owner || ownerHandle ? ( +
+ +
+ ) : null} {nixPlugin ? (
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
) : null} - - {showHeroMeta ? ( -
- {forkOf && forkOfHref ? ( - - {forkOfLabel}{" "} - - {forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""} - {forkOf.skill.slug} - - {forkOf.version ? ` (${forkOf.version})` : null} - - ) : null} - {canonicalHref ? ( - <> - {forkOf && forkOfHref ? ( - ยท - ) : null} - - canonical:{" "} - - {canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""} - {canonical?.skill?.slug} - - - - ) : null} -
- ) : null} } diff --git a/src/components/UserBadge.test.tsx b/src/components/UserBadge.test.tsx index a13a6224..b5313149 100644 --- a/src/components/UserBadge.test.tsx +++ b/src/components/UserBadge.test.tsx @@ -142,6 +142,29 @@ describe("UserBadge", () => { expect(container.querySelector(".official-tag")).toBeFalsy(); }); + it("places the official badge beside the display name in hero creator layout", () => { + const { container } = render( + + + , + ); + + const nameRow = container.querySelector(".user-name-row"); + expect(nameRow?.querySelector(".user-name")?.textContent).toBe("OpenClaw"); + expect(nameRow?.querySelector(".official-badge-icon-only")).toBeTruthy(); + expect(nameRow?.querySelector(".official-badge")).toBeFalsy(); + expect(container.querySelector(".user-badge > .official-badge")).toBeFalsy(); + }); + it("falls back to the legacy hover metric during rollout", () => { expect( getHoverTotalDownloads({ diff --git a/src/components/UserBadge.tsx b/src/components/UserBadge.tsx index 0a83e560..4107f8d2 100644 --- a/src/components/UserBadge.tsx +++ b/src/components/UserBadge.tsx @@ -30,6 +30,8 @@ type UserBadgeProps = { showHandle?: boolean; /** Sidebar creator row: `Display Name / @handle` with muted handle suffix. */ showMutedHandle?: boolean; + /** Hero creator row: stack `@handle` below the display name. */ + stackMutedHandleBelowName?: boolean; disableTooltip?: boolean; }; @@ -42,6 +44,7 @@ export function UserBadge({ showName = false, showHandle = true, showMutedHandle = false, + stackMutedHandleBelowName = false, disableTooltip = false, }: UserBadgeProps) { const userName = @@ -51,7 +54,10 @@ export function UserBadge({ const href = handle ? buildPublisherProfileHref(handle) : null; const label = handle ? `@${handle}` : "user"; const image = user?.image ?? null; - const showInlineMutedHandle = showMutedHandle && Boolean(handle) && Boolean(displayName); + const showStackedMutedHandle = + stackMutedHandleBelowName && showMutedHandle && Boolean(handle) && Boolean(displayName); + const showInlineMutedHandle = + !stackMutedHandleBelowName && showMutedHandle && Boolean(handle) && Boolean(displayName); const resolvedShowHandle = showMutedHandle ? !displayName && Boolean(handle) : showHandle; const hasUsefulName = showName && @@ -68,6 +74,14 @@ export function UserBadge({ const userId = user && hasOwnProperty(user, "kind") ? (user.linkedUserId ?? null) : (user?._id ?? null); + const officialBadge = isOfficial ? ( + + ) : null; + const badgeContent = ( <> {prefix ? {prefix} : null} @@ -80,7 +94,10 @@ export function UserBadge({ {hasUsefulName ? ( <> - {displayName} + + {displayName} + {officialBadge} + {showInlineMutedHandle ? ( <>