feat(ui): move creator into skill and plugin detail hero (#2842)

* feat(ui): move creator into skill and plugin detail hero

Show publisher name, handle, and official badge below the summary in the
shared detail hero, remove the sidebar Creator row, and resolve official
status from backend publisher data plus client fallback lookup.

* fix(ui): avoid extra official publisher detail reads

* fix(ui): align creator hero types with package API
This commit is contained in:
Vyctor H. Brzezowski
2026-06-24 14:09:14 -07:00
committed by GitHub
parent 6f537bf7ad
commit 40e345f4a9
14 changed files with 388 additions and 112 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+5 -3
View File
@@ -970,10 +970,11 @@ describe("plugin detail route", () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
const { container } = render(<Component />);
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(
+10 -1
View File
@@ -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",
+2
View File
@@ -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;
+15 -2
View File
@@ -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 <BadgeCheck size={size} className={iconClassName} aria-label="Official" />;
}
return (
<span
className={className ? `official-badge ${className}` : "official-badge"}
aria-label="Official"
title="Official"
>
<BadgeCheck size={12} aria-hidden="true" />
<BadgeCheck size={size} aria-hidden="true" />
</span>
);
}
+37 -8
View File
@@ -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();
+47 -48
View File
@@ -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}
<div className="skill-hero-title-row">
<h1 className="skill-page-title">{skill.displayName}</h1>
{isOfficial ? <OfficialTag /> : null}
{showTitleBadges ? (
<div className="skill-title-badges">
{titleBadges.map((badge) => (
@@ -413,18 +415,29 @@ export function SkillHeader({
) : null}
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
</div>
{owner || ownerHandle ? (
<div className="skill-hero-mobile-creator">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix=""
size="md"
showName
showHandle={false}
showMutedHandle
disableTooltip
/>
{showHeroMeta ? (
<div className="skill-hero-meta-row" aria-label="Skill lineage">
{forkOf && forkOfHref ? (
<span className="skill-hero-meta-item">
<span className="skill-hero-meta-label">{forkOfLabel}</span>
<a className="skill-hero-meta-link" href={forkOfHref}>
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
{forkOf.skill.slug}
</a>
{forkOf.version ? (
<span className="skill-hero-meta-version">({forkOf.version})</span>
) : null}
</span>
) : null}
{canonicalHref ? (
<span className="skill-hero-meta-item">
<span className="skill-hero-meta-label">canonical</span>
<a className="skill-hero-meta-link" href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
) : null}
</div>
) : null}
</div>
@@ -447,41 +460,27 @@ export function SkillHeader({
</button>
) : null}
</div>
{owner || ownerHandle ? (
<div className="skill-hero-creator">
<UserBadge
user={heroCreatorPublisher}
fallbackHandle={ownerHandle}
prefix=""
size="md"
showName
showHandle={false}
showMutedHandle
stackMutedHandleBelowName
disableTooltip
/>
</div>
) : null}
{nixPlugin ? (
<div className="skill-hero-note">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
</div>
) : null}
{showHeroMeta ? (
<div className="skill-hero-meta-row">
{forkOf && forkOfHref ? (
<span className="stat">
{forkOfLabel}{" "}
<a href={forkOfHref}>
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
{forkOf.skill.slug}
</a>
{forkOf.version ? ` (${forkOf.version})` : null}
</span>
) : null}
{canonicalHref ? (
<>
{forkOf && forkOfHref ? (
<span className="text-ink-soft opacity-40">·</span>
) : null}
<span className="stat">
canonical:{" "}
<a href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
</>
) : null}
</div>
) : null}
</div>
</>
}
+23
View File
@@ -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(
<TooltipProvider>
<UserBadge
user={orgPublisher}
prefix=""
size="md"
showName
showHandle={false}
showMutedHandle
stackMutedHandleBelowName
disableTooltip
/>
</TooltipProvider>,
);
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({
+23 -3
View File
@@ -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 ? (
<OfficialBadge
className="user-name-official-badge"
iconOnly={stackMutedHandleBelowName}
size={stackMutedHandleBelowName ? 14 : 12}
/>
) : null;
const badgeContent = (
<>
{prefix ? <span className="user-badge-prefix">{prefix}</span> : null}
@@ -80,7 +94,10 @@ export function UserBadge({
</span>
{hasUsefulName ? (
<>
<span className="user-name">{displayName}</span>
<span className="user-name-row">
<span className="user-name">{displayName}</span>
{officialBadge}
</span>
{showInlineMutedHandle ? (
<>
<span className="user-name-sep" aria-hidden="true">
@@ -95,8 +112,11 @@ export function UserBadge({
) : null}
</>
) : null}
{showStackedMutedHandle ? (
<span className="user-handle user-handle-muted">{label}</span>
) : null}
{resolvedShowHandle ? <span className="user-handle">{label}</span> : null}
{isOfficial ? <OfficialBadge /> : null}
{isOfficial && !hasUsefulName ? officialBadge : null}
</>
);
+44
View File
@@ -0,0 +1,44 @@
/* @vitest-environment jsdom */
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PublicPublisher } from "./publicUser";
import { useHeroCreatorPublisher } from "./useHeroCreatorPublisher";
const useQueryMock = vi.fn();
vi.mock("convex/react", () => ({
useQuery: (...args: unknown[]) => useQueryMock(...args),
}));
const owner = {
_id: "publishers:openclaw",
_creationTime: 1,
kind: "org",
handle: "openclaw",
displayName: "OpenClaw",
} as PublicPublisher;
describe("useHeroCreatorPublisher", () => {
beforeEach(() => {
useQueryMock.mockReset();
});
it("enriches detail owners from the canonical publisher lookup", () => {
useQueryMock.mockReturnValue({ ...owner, official: true });
const { result } = renderHook(() => useHeroCreatorPublisher({ owner }));
expect(useQueryMock.mock.calls[0]?.[1]).toEqual({ handle: "openclaw" });
expect(result.current).toEqual({ ...owner, official: true });
});
it("skips the publisher lookup when the item already proves official status", () => {
useQueryMock.mockReturnValue(undefined);
const { result } = renderHook(() => useHeroCreatorPublisher({ owner, skillOfficial: true }));
expect(useQueryMock.mock.calls[0]?.[1]).toBe("skip");
expect(result.current).toEqual({ ...owner, official: true });
});
});
+41
View File
@@ -0,0 +1,41 @@
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
type HeroCreatorPublisher = {
_id?: string;
_creationTime?: number;
kind?: "user" | "org";
handle?: string | null;
displayName?: string | null;
image?: string | null;
bio?: string | null;
linkedUserId?: string;
official?: boolean;
};
type UseHeroCreatorPublisherArgs = {
owner: HeroCreatorPublisher | null | undefined;
skillOfficial?: boolean;
packageOfficial?: boolean;
};
export function useHeroCreatorPublisher({
owner,
skillOfficial = false,
packageOfficial = false,
}: UseHeroCreatorPublisherArgs) {
const shouldLookupPublisherOfficial =
Boolean(owner?.handle) && owner?.official !== true && !skillOfficial && !packageOfficial;
const publisherOfficialLookup = useQuery(
api.publishers.getByHandle,
shouldLookupPublisherOfficial && owner?.handle ? { handle: owner.handle } : "skip",
) as HeroCreatorPublisher | null | undefined;
if (!owner) return owner;
const showOfficial =
owner.official === true ||
publisherOfficialLookup?.official === true ||
skillOfficial ||
packageOfficial;
return showOfficial ? { ...owner, official: true as const } : owner;
}
+19 -18
View File
@@ -29,7 +29,11 @@ import { useEffect, useMemo, useState, type ReactNode } from "react";
import { toast } from "sonner";
import { api } from "../../../convex/_generated/api";
import { CatalogMetadataEditor } from "../../components/CatalogMetadataEditor";
import { DetailHero, DetailPageShell } from "../../components/DetailPageShell";
import {
DetailHero,
DETAIL_HERO_TOPIC_LIMIT,
DetailPageShell,
} from "../../components/DetailPageShell";
import {
DetailSecuritySummary,
DetailSecuritySummaryLabel,
@@ -39,7 +43,6 @@ import { EmptyState } from "../../components/EmptyState";
import { InstallCopyButton } from "../../components/InstallCopyButton";
import { Container } from "../../components/layout/Container";
import { MarkdownPreview } from "../../components/MarkdownPreview";
import { OfficialTag } from "../../components/OfficialBadge";
import {
PLUGIN_VERSIONS_PAGE_SIZE,
PluginVersionsPanel,
@@ -94,6 +97,7 @@ import { buildReadmeAssetBaseUrl } from "../../lib/readmeAssetBaseUrl";
import { timeAgo } from "../../lib/timeAgo";
import { useAuthStatus } from "../../lib/useAuthStatus";
import { useDeferredPackageActivityTrend } from "../../lib/useDeferredActivityTrend";
import { useHeroCreatorPublisher } from "../../lib/useHeroCreatorPublisher";
import { useMediaQuery } from "../../lib/useMediaQuery";
type PluginDetailRateLimitState = {
@@ -1136,10 +1140,14 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
const headerTopics = (pkg.topics ?? [])
.map((topic) => topic.trim())
.filter(Boolean)
.slice(0, 5);
.slice(0, DETAIL_HERO_TOPIC_LIMIT);
const headerSummary = pkg.summary ?? "No summary provided.";
const hasSummaryToggle = headerSummary.length > 220;
const owner = detail.owner;
const heroCreatorPublisher = useHeroCreatorPublisher({
owner,
packageOfficial: pkg.isOfficial === true,
});
const latestRelease = version?.version ?? null;
const isDownloadBlocked =
pkg.scanStatus === "malicious" || latestRelease?.verification?.scanStatus === "malicious";
@@ -1431,24 +1439,20 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
);
})()
: null;
const ownerMetadataValue = owner ? (
const pluginHeroCreator = heroCreatorPublisher ? (
<UserBadge
user={{
...owner,
...(pkg.isOfficial ? { official: true as const } : {}),
}}
fallbackHandle={owner.handle ?? pkg.ownerHandle ?? null}
user={heroCreatorPublisher}
fallbackHandle={heroCreatorPublisher.handle ?? pkg.ownerHandle ?? null}
prefix=""
size="md"
showName
showHandle={false}
showMutedHandle
stackMutedHandleBelowName
disableTooltip
/>
) : null;
const hasSourceMetadata = Boolean(
sourceRepoLink || ownerMetadataValue || latestRelease || pkg.latestVersion,
);
const hasSourceMetadata = Boolean(sourceRepoLink || owner || latestRelease || pkg.latestVersion);
const securitySummary = latestRelease ? (
<DetailSecuritySummary
auditHref={buildPluginSecurityAuditHref(name, { ownerHandle: owner?.handle })}
@@ -1492,7 +1496,6 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
? [
pluginDownloadsMetricBlock,
{ label: "Repository", value: sourceRepoLink },
...(ownerMetadataValue ? [{ label: "Creator", value: ownerMetadataValue }] : []),
securitySummary
? {
key: "security-audit",
@@ -1634,11 +1637,6 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
) : null}
<div className="skill-hero-title-row">
<h1 className="skill-page-title">{pkg.displayName}</h1>
{pkg.isOfficial ? (
<div className="skill-title-badges">
<OfficialTag />
</div>
) : null}
{isDownloadBlocked ? (
<div className="skill-title-actions">
<Badge variant="destructive">Download blocked</Badge>
@@ -1665,6 +1663,9 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
</button>
) : null}
</div>
{pluginHeroCreator ? (
<div className="skill-hero-creator">{pluginHeroCreator}</div>
) : null}
{rateLimited?.scope === "metadata" ? (
<div className="skill-hero-badges">
+122 -29
View File
@@ -5984,6 +5984,7 @@ code {
.skill-hero-layout.has-sidebar {
grid-template-columns: minmax(0, 1fr) minmax(300px, 360px);
column-gap: clamp(56px, 7vw, 96px);
row-gap: 64px;
}
.skill-hero-layout.has-sidebar > .skill-hero-main {
@@ -6221,10 +6222,82 @@ code {
align-items: flex-start;
}
.skill-hero-mobile-creator {
.skill-hero-creator {
display: inline-flex;
min-width: 0;
margin-top: 12px;
}
.skill-hero-title > .skill-summary-block:has(+ .skill-hero-creator) {
margin-bottom: 0;
}
.skill-hero-creator .user-badge,
.skill-hero-creator .user-badge-link {
display: inline-grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto auto;
column-gap: 10px;
row-gap: 2px;
align-items: center;
min-width: 0;
color: var(--ink-soft);
text-decoration: none;
}
.skill-hero-creator .user-avatar {
grid-column: 1;
grid-row: 1 / span 2;
align-self: center;
}
.skill-hero-creator .user-name-row {
display: inline-flex;
align-items: center;
gap: 6px;
grid-column: 2;
grid-row: 1;
align-self: center;
min-width: 0;
}
.skill-hero-creator .user-name {
color: var(--ink);
font-weight: 680;
font-size: 0.88rem;
line-height: 1.15;
}
.skill-hero-creator .official-badge-icon-only {
flex: 0 0 auto;
color: var(--official-fg);
}
.skill-hero-creator .user-name-sep {
display: none;
}
.skill-hero-creator .user-handle {
grid-column: 2;
grid-row: 2;
color: var(--ink-soft);
font-weight: 560;
font-size: 0.8rem;
line-height: 1.15;
}
.skill-hero-creator .user-badge-link:hover,
.skill-hero-creator .user-badge-link:focus-visible {
text-decoration: none;
}
.skill-hero-creator .user-badge-link:hover .user-name,
.skill-hero-creator .user-badge-link:focus-visible .user-name,
.skill-hero-creator .user-badge-link:hover .user-handle,
.skill-hero-creator .user-badge-link:focus-visible .user-handle {
text-decoration: none;
}
.plugin-catalog-empty-alert {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
@@ -6876,7 +6949,7 @@ code {
.detail-mobile-master-tab-list[hidden],
.detail-mobile-master-panel[hidden],
.skill-hero-mobile-creator[hidden] {
.skill-hero-creator[hidden] {
display: none !important;
}
@@ -7407,11 +7480,44 @@ code {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
gap: 8px 14px;
min-width: 0;
margin-top: 2px;
overflow-wrap: anywhere;
}
.skill-hero-meta-item {
display: inline-flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px;
min-width: 0;
font-size: 0.82rem;
line-height: 1.3;
}
.skill-hero-meta-label {
color: var(--ink-soft);
font-weight: 520;
}
.skill-hero-meta-link {
color: var(--accent);
font-weight: 560;
text-decoration: none;
}
.skill-hero-meta-link:hover,
.skill-hero-meta-link:focus-visible {
color: var(--accent);
text-decoration: underline;
}
.skill-hero-meta-version {
color: var(--ink-soft);
font-weight: 520;
}
.skill-hero-badges {
display: flex;
flex-wrap: wrap;
@@ -8246,33 +8352,20 @@ code {
gap: 10px;
}
.skill-hero-mobile-creator {
display: inline-flex;
align-items: center;
:is(.skill-detail-page, .plugin-detail-page) .skill-summary-block {
margin-top: 4px;
}
:is(.skill-detail-page, .plugin-detail-page) .skill-hero-creator {
margin-top: 18px;
}
.skill-detail-page .skill-hero-lower.has-sidebar {
order: 3;
display: grid;
gap: 12px;
width: 100%;
min-width: 0;
color: var(--ink-soft);
}
.skill-hero-mobile-creator .user-badge {
min-width: 0;
}
.skill-hero-mobile-creator .user-badge-md .user-name,
.skill-hero-mobile-creator .user-badge-md .user-name-sep,
.skill-hero-mobile-creator .user-badge-md .user-handle {
font-size: 0.88rem;
line-height: 1.15;
}
.skill-hero-mobile-creator .user-badge-md .user-name {
color: var(--ink);
font-weight: 680;
}
.skill-hero-mobile-creator .user-badge-md .user-name-sep,
.skill-hero-mobile-creator .user-badge-md .user-handle {
color: var(--ink-soft);
font-weight: 560;
}
.detail-mobile-master-tabs {