feat: make Featured the default ClawHub catalog (#3096)

* feat: make home catalog featured-first

* fix: order plugins before skills on home

* feat: refine featured catalog landing page

* fix: seed featured catalog previews

* fix: reduce official creator shelf
This commit is contained in:
Patrick Erichsen
2026-07-15 21:55:18 -07:00
committed by GitHub
parent 90dcbf291c
commit 3134a20492
23 changed files with 1449 additions and 462 deletions
+129
View File
@@ -4,6 +4,7 @@ import {
backfillExistingPublicCorpusBatchRows,
currentUserSeedPackageName,
currentUserSeedSkillSlug,
seedCatalogPresentationFixtures,
seedFeaturedPluginPackagesMutation,
seedGitHubBackedSkillSourceMutation,
seedLocalFixtures,
@@ -23,6 +24,9 @@ const seedSkillMutationHandler = (
const seedFeaturedPluginPackagesHandler = (
seedFeaturedPluginPackagesMutation as unknown as WrappedHandler<Record<string, unknown>>
)._handler;
const seedCatalogPresentationFixturesHandler = (
seedCatalogPresentationFixtures as unknown as WrappedHandler<Record<string, unknown>>
)._handler;
const seedGitHubBackedSkillSourceHandler = (
seedGitHubBackedSkillSourceMutation as unknown as WrappedHandler<Record<string, unknown>>
)._handler;
@@ -1503,4 +1507,129 @@ describe("devSeed local fixtures", () => {
expect(oldPackageDeleteIndex).toBeGreaterThanOrEqual(0);
expect(oldReleaseDeleteIndex).toBeGreaterThan(oldPackageDeleteIndex);
});
it("seeds repeatable official creator fixtures with featured skills and plugins", async () => {
const { db, tables } = createDb();
const userId = (await db.insert("users", {
handle: "local-corpus-owner",
displayName: "Corpus Owner",
role: "user",
createdAt: 1,
updatedAt: 1,
})) as Id<"users">;
const sourcePublisherId = (await db.insert("publishers", {
kind: "user",
handle: "local-corpus-owner",
displayName: "Corpus Owner",
linkedUserId: userId,
publishedSkills: 1,
publishedPackages: 1,
totalInstalls: 12,
totalDownloads: 24,
totalStars: 6,
skillTotalInstalls: 5,
skillTotalDownloads: 10,
skillTotalStars: 2,
createdAt: 1,
updatedAt: 1,
})) as Id<"publishers">;
const skillId = (await db.insert("skills", {
slug: "presentation-skill",
displayName: "Presentation Skill",
ownerUserId: userId,
ownerPublisherId: sourcePublisherId,
latestVersionId: undefined,
tags: {},
badges: { highlighted: undefined, redactionApproved: undefined },
softDeletedAt: undefined,
statsDownloads: 10,
statsStars: 2,
statsInstallsCurrent: 3,
statsInstallsAllTime: 5,
stats: {
downloads: 10,
installsCurrent: 3,
installsAllTime: 5,
stars: 2,
versions: 0,
comments: 0,
},
createdAt: 1,
updatedAt: 1,
})) as Id<"skills">;
const skillVersionId = (await db.insert("skillVersions", {
skillId,
version: "1.0.0",
changelog: "Presentation fixture.",
files: [],
parsed: { frontmatter: {}, metadata: {} },
createdBy: userId,
createdAt: 1,
softDeletedAt: undefined,
})) as Id<"skillVersions">;
await db.patch(skillId, {
latestVersionId: skillVersionId,
tags: { latest: skillVersionId },
stats: {
downloads: 10,
installsCurrent: 3,
installsAllTime: 5,
stars: 2,
versions: 1,
comments: 0,
},
});
const packageId = (await db.insert("packages", {
name: "presentation-plugin",
normalizedName: "presentation-plugin",
displayName: "Presentation Plugin",
ownerUserId: userId,
ownerPublisherId: sourcePublisherId,
family: "code-plugin",
channel: "community",
isOfficial: false,
runtimeId: "presentation-plugin",
tags: {},
stats: { downloads: 14, installs: 7, stars: 4, versions: 1 },
softDeletedAt: undefined,
createdAt: 1,
updatedAt: 1,
})) as Id<"packages">;
const args = {
orgs: [
{
sourceOwnerHandle: "local-corpus-owner",
handle: "catalog-atlas",
displayName: "Atlas Automation",
bio: "Synthetic official creator.",
image: "https://example.invalid/atlas.svg",
skillSlug: "presentation-skill",
packageName: "presentation-plugin",
featured: true,
},
],
};
await seedCatalogPresentationFixturesHandler(createMutationCtx(db) as never, args as never);
await seedCatalogPresentationFixturesHandler(createMutationCtx(db) as never, args as never);
const org = tables.publishers?.find((publisher) => publisher.handle === "catalog-atlas");
expect(org).toEqual(
expect.objectContaining({
kind: "org",
displayName: "Atlas Automation",
publishedSkills: 1,
publishedPackages: 1,
totalInstalls: 12,
}),
);
expect(tables.officialPublishers).toHaveLength(1);
expect(tables.publisherMembers).toHaveLength(1);
expect(tables.skills?.find((skill) => skill._id === skillId)?.ownerPublisherId).toBe(org?._id);
expect(tables.packages?.find((pkg) => pkg._id === packageId)?.ownerPublisherId).toBe(org?._id);
expect(tables.skillBadges).toEqual([expect.objectContaining({ skillId, kind: "highlighted" })]);
expect(tables.packageBadges).toEqual([
expect.objectContaining({ packageId, kind: "highlighted" }),
]);
});
});
+143
View File
@@ -23,6 +23,7 @@ import {
upsertPackageSearchDigest,
} from "./lib/packageSearchDigest";
import { ensurePersonalPublisherForUser } from "./lib/publishers";
import { recomputePublisherStats } from "./lib/publisherStats";
import {
computeRecommendationScore,
RECOMMENDATION_SCORE_VERSION,
@@ -1369,6 +1370,148 @@ export const seedPublicCorpusBatchMutation = internalMutation({
},
});
export const seedCatalogPresentationFixtures = internalMutation({
args: {
orgs: v.array(
v.object({
sourceOwnerHandle: v.string(),
handle: v.string(),
displayName: v.string(),
bio: v.string(),
image: v.string(),
skillSlug: v.string(),
packageName: v.string(),
featured: v.boolean(),
}),
),
},
handler: async (ctx, args) => {
const now = Date.now();
const affectedPublisherIds = new Set<Id<"publishers">>();
const seeded: string[] = [];
for (const spec of args.orgs) {
const sourcePublisher = await ctx.db
.query("publishers")
.withIndex("by_handle", (q) => q.eq("handle", spec.sourceOwnerHandle))
.unique();
if (!sourcePublisher || sourcePublisher.kind !== "user" || !sourcePublisher.linkedUserId) {
throw new Error(`Catalog presentation source owner not found: ${spec.sourceOwnerHandle}`);
}
const skill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", spec.skillSlug))
.unique();
const pkg = await findSeedPluginFixtureByName(ctx, spec.packageName);
if (!skill || !pkg) {
throw new Error(`Catalog presentation content not found for ${spec.handle}`);
}
const existingPublisher = await ctx.db
.query("publishers")
.withIndex("by_handle", (q) => q.eq("handle", spec.handle))
.unique();
if (existingPublisher && existingPublisher.kind !== "org") {
throw new Error(`Catalog presentation handle is not an organization: ${spec.handle}`);
}
const publisherPatch = {
kind: "org" as const,
handle: spec.handle,
displayName: spec.displayName,
bio: spec.bio,
image: spec.image,
linkedUserId: undefined,
trustedPublisher: false,
deletedAt: undefined,
deactivatedAt: undefined,
updatedAt: now,
};
const publisherId =
existingPublisher?._id ??
(await ctx.db.insert("publishers", {
...publisherPatch,
publishedSkills: 0,
publishedPackages: 0,
totalInstalls: 0,
totalDownloads: 0,
totalStars: 0,
skillTotalInstalls: 0,
skillTotalDownloads: 0,
skillTotalStars: 0,
createdAt: now,
}));
if (existingPublisher) {
await ctx.db.patch(existingPublisher._id, publisherPatch);
}
const official = await ctx.db
.query("officialPublishers")
.withIndex("by_publisher", (q) => q.eq("publisherId", publisherId))
.unique();
const officialPatch = {
reason: "Synthetic local and preview catalog presentation fixture.",
createdByUserId: sourcePublisher.linkedUserId,
updatedAt: now,
};
if (official) {
await ctx.db.patch(official._id, officialPatch);
} else {
await ctx.db.insert("officialPublishers", {
publisherId,
...officialPatch,
createdAt: now,
});
}
const membership = await ctx.db
.query("publisherMembers")
.withIndex("by_publisher_user", (q) =>
q.eq("publisherId", publisherId).eq("userId", sourcePublisher.linkedUserId!),
)
.unique();
if (membership) {
await ctx.db.patch(membership._id, { role: "owner", updatedAt: now });
} else {
await ctx.db.insert("publisherMembers", {
publisherId,
userId: sourcePublisher.linkedUserId,
role: "owner",
createdAt: now,
updatedAt: now,
});
}
if (skill.ownerPublisherId !== publisherId) {
if (skill.ownerPublisherId) affectedPublisherIds.add(skill.ownerPublisherId);
await ctx.db.patch(skill._id, { ownerPublisherId: publisherId, updatedAt: now });
}
if (pkg.ownerPublisherId !== publisherId) {
if (pkg.ownerPublisherId) affectedPublisherIds.add(pkg.ownerPublisherId);
await ctx.db.patch(pkg._id, { ownerPublisherId: publisherId, updatedAt: now });
}
if (spec.featured) {
await ensureHighlightedSkillBadge(ctx, skill._id, sourcePublisher.linkedUserId, now);
await ensureHighlightedPackageBadge(ctx, pkg._id, sourcePublisher.linkedUserId, now);
}
affectedPublisherIds.add(publisherId);
seeded.push(spec.handle);
}
for (const publisherId of affectedPublisherIds) {
if (!(await ctx.db.get(publisherId))) continue;
await ctx.db.patch(publisherId, {
...(await recomputePublisherStats(ctx, publisherId)),
updatedAt: now,
});
}
return { ok: true as const, seeded };
},
});
function publicCorpusSummaryFromFrontmatter(frontmatter: Record<string, unknown>) {
if (typeof frontmatter.description === "string" && frontmatter.description.trim()) {
return frontmatter.description.trim();
+9 -3
View File
@@ -1,10 +1,10 @@
import { PACKAGE_TRENDING_LEADERBOARD_LIMIT } from "clawhub-schema";
import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { internalAction, internalMutation, internalQuery } from "./functions";
import { getTrendingRange, topN, TRENDING_DAYS } from "./lib/leaderboards";
const MAX_TRENDING_LIMIT = 200;
const DAILY_STATS_PAGE_SIZE = 1_000;
const KEEP_LEADERBOARD_ENTRIES = 3;
export const PACKAGE_TRENDING_LEADERBOARD_KIND = "package_trending";
@@ -73,7 +73,10 @@ export const writeTrendingLeaderboard = internalMutation({
export const rebuildTrendingLeaderboardAction = internalAction({
args: { limit: v.optional(v.number()) },
handler: async (ctx, args): Promise<{ ok: true; count: number }> => {
const limit = Math.min(Math.max(args.limit ?? MAX_TRENDING_LIMIT, 1), MAX_TRENDING_LIMIT);
const limit = Math.min(
Math.max(args.limit ?? PACKAGE_TRENDING_LEADERBOARD_LIMIT, 1),
PACKAGE_TRENDING_LEADERBOARD_LIMIT,
);
const now = Date.now();
const { startDay, endDay } = getTrendingRange(now);
const totals = new Map<Id<"packages">, { installs: number; downloads: number }>();
@@ -126,7 +129,10 @@ export const rebuildTrendingLeaderboardInternal = internalMutation({
args: { limit: v.optional(v.number()) },
handler: async (ctx, args) => {
await ctx.scheduler.runAfter(0, internal.packageLeaderboards.rebuildTrendingLeaderboardAction, {
limit: Math.min(Math.max(args.limit ?? MAX_TRENDING_LIMIT, 1), MAX_TRENDING_LIMIT),
limit: Math.min(
Math.max(args.limit ?? PACKAGE_TRENDING_LEADERBOARD_LIMIT, 1),
PACKAGE_TRENDING_LEADERBOARD_LIMIT,
),
});
return { ok: true as const, count: 0, scheduled: true as const, days: TRENDING_DAYS };
},
+118
View File
@@ -15,6 +15,8 @@ import {
listMine,
getDeletionInventory,
getMyProfileHandle,
getHomeOfficialCreatorSummaries,
getHomeOfficialCreatorSummariesPageInternal,
getHomePublisherSummaries,
getProfileByHandle,
createMemberInvite,
@@ -320,6 +322,16 @@ const getHomePublisherSummariesHandler = (
getHomePublisherSummaries as unknown as WrappedHandler<{ handles: string[] }>
)._handler;
const getHomeOfficialCreatorSummariesHandler = (
getHomeOfficialCreatorSummaries as unknown as WrappedHandler<{ limit?: number }>
)._handler;
const getHomeOfficialCreatorSummariesPageInternalHandler = (
getHomeOfficialCreatorSummariesPageInternal as unknown as WrappedHandler<{
cursor: string | null;
}>
)._handler;
const getOgMetaByHandleHandler = (
getOgMetaByHandle as unknown as WrappedHandler<
{ handle: string },
@@ -936,6 +948,109 @@ describe("home publisher summaries", () => {
});
});
describe("home official creator summaries", () => {
it("pages the official publisher index and returns only active organizations with content", async () => {
const publishers = [
makeHomeSummaryPublisher("high", { totalInstalls: 100 }),
makeHomeSummaryPublisher("person", {
kind: "user",
linkedUserId: "users:person",
totalInstalls: 80,
}),
makeHomeSummaryPublisher("empty", {
publishedSkills: 0,
publishedPackages: 0,
totalInstalls: 70,
}),
makeHomeSummaryPublisher("inactive", { deactivatedAt: 2, totalInstalls: 50 }),
];
const officialRows = publishers.map((publisher, index) => ({
_id: `officialPublishers:${index}`,
publisherId: publisher._id,
createdAt: index,
updatedAt: index,
}));
const query = vi.fn((table: string) => {
if (table === "officialPublishers") {
return {
withIndex: vi.fn((indexName: string) => {
expect(indexName).toBe("by_created");
return indexedRows(officialRows);
}),
};
}
throw new Error(`unexpected table ${table}`);
});
const get = vi.fn(async (id: string) => {
if (id === "users:person") return { _id: id, displayName: "Person" };
return publishers.find((publisher) => publisher._id === id) ?? null;
});
const result = (await getHomeOfficialCreatorSummariesPageInternalHandler(
{ db: { query, get } } as never,
{ cursor: null },
)) as {
summaries: Array<{ handle: string; kind: string }>;
isDone: boolean;
continueCursor: string;
};
expect(result.summaries.map((summary) => summary.handle)).toEqual(["high"]);
expect(result.summaries.every((summary) => summary.kind === "org")).toBe(true);
expect(result.isDone).toBe(true);
});
it("collects every official page, orders by installs, and clamps the result to sixteen", async () => {
const makeSummary = (index: number, installs: number) => ({
...makeHomeSummaryPublisher(`org-${index}`, { totalInstalls: installs }),
stats: { skills: 2, packages: 1, installs, downloads: installs, stars: 5 },
});
const firstPage = Array.from({ length: 12 }, (_, index) => makeSummary(index, 20 - index));
const secondPage = Array.from({ length: 8 }, (_, index) =>
makeSummary(index + 12, 100 - index),
);
const runQuery = vi
.fn()
.mockResolvedValueOnce({
summaries: firstPage,
continueCursor: "page-2",
isDone: false,
})
.mockResolvedValueOnce({
summaries: secondPage,
continueCursor: "",
isDone: true,
});
const summaries = (await getHomeOfficialCreatorSummariesHandler({ runQuery } as never, {
limit: 99,
})) as Array<{ handle: string; stats: { installs: number } }>;
expect(summaries).toHaveLength(16);
expect(summaries.map((summary) => summary.stats.installs)).toEqual([
100, 99, 98, 97, 96, 95, 94, 93, 20, 19, 18, 17, 16, 15, 14, 13,
]);
expect(runQuery).toHaveBeenNthCalledWith(1, expect.anything(), { cursor: null });
expect(runQuery).toHaveBeenNthCalledWith(2, expect.anything(), { cursor: "page-2" });
});
it("fails closed when legacy official rows exceed the bounded curation limit", async () => {
const runQuery = vi.fn();
for (let page = 0; page < 4; page += 1) {
runQuery.mockResolvedValueOnce({
summaries: [],
continueCursor: `page-${page + 1}`,
isDone: false,
});
}
await expect(getHomeOfficialCreatorSummariesHandler({ runQuery } as never, {})).rejects.toThrow(
"Official publisher limit exceeded",
);
expect(runQuery).toHaveBeenCalledTimes(4);
});
});
describe("publishers membership controls", () => {
it("lets an org owner delete an org and cascade owned resources", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
@@ -6600,6 +6715,9 @@ describe("official publisher administration", () => {
if (table === "officialPublishers" && indexName === "by_publisher") {
return { unique: vi.fn(async () => null) };
}
if (table === "officialPublishers" && indexName === "by_created") {
return { take: vi.fn(async () => []) };
}
throw new Error(`unexpected ${table} index ${indexName}`);
},
),
+79 -1
View File
@@ -3,7 +3,7 @@ import { ConvexError, v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { internalMutation, internalQuery, mutation, query } from "./functions";
import { action, internalMutation, internalQuery, mutation, query } from "./functions";
import { assertAdmin, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
import { isPublicSkillDoc } from "./lib/globalStats";
import { isOfficialPublisher, toPublicPublisherWithOfficial } from "./lib/officialPublishers";
@@ -61,6 +61,10 @@ const PUBLISHER_OG_AFFILIATION_LIMIT = 5;
const PUBLISHER_OG_MEMBERSHIP_PAGE_SIZE = 64;
const PUBLISHER_OG_MEMBERSHIP_SCAN_LIMIT = 512;
const MAX_HOME_PUBLISHER_SUMMARIES = 10;
const MAX_HOME_OFFICIAL_CREATOR_SUMMARIES = 16;
const HOME_OFFICIAL_CREATOR_PAGE_SIZE = 32;
// Official status is staff-curated. Keep the set bounded so public home/browse reads stay predictable.
const MAX_OFFICIAL_PUBLISHER_COUNT = 128;
const publisherRoleValidator = v.union(
v.literal("owner"),
v.literal("admin"),
@@ -2428,6 +2432,72 @@ export const getHomePublisherSummaries = query({
},
});
export const getHomeOfficialCreatorSummariesPageInternal = internalQuery({
args: { cursor: v.union(v.string(), v.null()) },
handler: async (ctx, args) => {
const page = await ctx.db
.query("officialPublishers")
.withIndex("by_created", (q) => q)
.paginate({ cursor: args.cursor, numItems: HOME_OFFICIAL_CREATOR_PAGE_SIZE });
const summaries = (
await Promise.all(
page.page.map(async (row) =>
toHomePublisherSummary(ctx, await ctx.db.get(row.publisherId)),
),
)
).filter(
(summary): summary is NonNullable<Awaited<ReturnType<typeof toHomePublisherSummary>>> =>
Boolean(
summary && summary.kind === "org" && summary.stats.skills + summary.stats.packages > 0,
),
);
return {
summaries,
continueCursor: page.continueCursor,
isDone: page.isDone,
};
},
});
export const getHomeOfficialCreatorSummaries: ReturnType<typeof action> = action({
args: { limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const limit = clampInt(
args.limit ?? MAX_HOME_OFFICIAL_CREATOR_SUMMARIES,
1,
MAX_HOME_OFFICIAL_CREATOR_SUMMARIES,
);
const summaries: Array<NonNullable<Awaited<ReturnType<typeof toHomePublisherSummary>>>> = [];
let cursor: string | null = null;
let scanned = 0;
while (scanned < MAX_OFFICIAL_PUBLISHER_COUNT) {
const page: {
summaries: Array<NonNullable<Awaited<ReturnType<typeof toHomePublisherSummary>>>>;
continueCursor: string;
isDone: boolean;
} = await ctx.runQuery(internal.publishers.getHomeOfficialCreatorSummariesPageInternal, {
cursor,
});
summaries.push(...page.summaries);
scanned += HOME_OFFICIAL_CREATOR_PAGE_SIZE;
if (page.isDone || page.continueCursor === cursor) break;
if (scanned >= MAX_OFFICIAL_PUBLISHER_COUNT) {
throw new ConvexError("Official publisher limit exceeded");
}
cursor = page.continueCursor;
}
return summaries
.sort(
(left, right) =>
right.stats.installs - left.stats.installs || left.handle.localeCompare(right.handle),
)
.slice(0, limit);
},
});
export const getOgMetaByHandle = query({
args: { handle: v.string() },
handler: async (ctx, args) => {
@@ -3532,6 +3602,14 @@ export const addOfficialPublisherInternal = internalMutation({
};
}
const officialPublisherLimitProbe = await ctx.db
.query("officialPublishers")
.withIndex("by_created", (q) => q)
.take(MAX_OFFICIAL_PUBLISHER_COUNT);
if (officialPublisherLimitProbe.length >= MAX_OFFICIAL_PUBLISHER_COUNT) {
throw new ConvexError(`Official publisher limit reached (${MAX_OFFICIAL_PUBLISHER_COUNT})`);
}
const now = Date.now();
const officialPublisherId = await ctx.db.insert("officialPublishers", {
publisherId: publisher._id,
+11 -10
View File
@@ -1,4 +1,5 @@
import { type inferred } from "arktype";
export declare const PACKAGE_TRENDING_LEADERBOARD_LIMIT = 200;
export declare function normalizePackageOwnerHandle(handle: string | null | undefined): string | undefined;
export declare function inferPackageNameScope(name: string): string | undefined;
export declare function getPackageScopeOwnerMismatch(name: string, ownerHandle: string | null | undefined): {
@@ -77,11 +78,11 @@ export declare const PackageStatsSchema: import("arktype/internal/variants/objec
export type PackageStats = (typeof PackageStatsSchema)[inferred];
export declare const PackageArtifactKindSchema: import("arktype/internal/variants/string.ts").StringType<"legacy-zip" | "npm-pack", {}>;
export type PackageArtifactKind = (typeof PackageArtifactKindSchema)[inferred];
export declare const PackageReleaseModerationStateSchema: import("arktype/internal/variants/string.ts").StringType<"approved" | "quarantined" | "revoked", {}>;
export declare const PackageReleaseModerationStateSchema: import("arktype/internal/variants/string.ts").StringType<"approved" | "revoked" | "quarantined", {}>;
export type PackageReleaseModerationState = (typeof PackageReleaseModerationStateSchema)[inferred];
export declare const PackageReportStatusSchema: import("arktype/internal/variants/string.ts").StringType<"open" | "confirmed" | "dismissed", {}>;
export type PackageReportStatus = (typeof PackageReportStatusSchema)[inferred];
export declare const PackageReportFinalActionSchema: import("arktype/internal/variants/string.ts").StringType<"none" | "quarantine" | "revoke", {}>;
export declare const PackageReportFinalActionSchema: import("arktype/internal/variants/string.ts").StringType<"revoke" | "none" | "quarantine", {}>;
export type PackageReportFinalAction = (typeof PackageReportFinalActionSchema)[inferred];
export declare const PackageReportListStatusSchema: import("arktype/internal/variants/string.ts").StringType<"open" | "confirmed" | "dismissed" | "all", {}>;
export type PackageReportListStatus = (typeof PackageReportListStatusSchema)[inferred];
@@ -717,12 +718,12 @@ export declare const ApiV1PackageSecurityResponseSchema: import("arktype/interna
reasons: string[];
pending: boolean;
stale: boolean;
moderationState?: "approved" | "quarantined" | "revoked" | null | undefined;
moderationState?: "approved" | "revoked" | "quarantined" | null | undefined;
};
}, {}>;
export type ApiV1PackageSecurityResponse = (typeof ApiV1PackageSecurityResponseSchema)[inferred];
export declare const PackageReleaseModerationRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
state: "approved" | "quarantined" | "revoked";
state: "approved" | "revoked" | "quarantined";
reason: string;
}, {}>;
export type PackageReleaseModerationRequest = (typeof PackageReleaseModerationRequestSchema)[inferred];
@@ -743,7 +744,7 @@ export type ApiV1PackageReportResponse = (typeof ApiV1PackageReportResponseSchem
export declare const PackageReportTriageRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
status: "open" | "confirmed" | "dismissed";
note?: string | undefined;
finalAction?: "none" | "quarantine" | "revoke" | undefined;
finalAction?: "revoke" | "none" | "quarantine" | undefined;
}, {}>;
export type PackageReportTriageRequest = (typeof PackageReportTriageRequestSchema)[inferred];
export declare const PackageAppealRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -822,7 +823,7 @@ export declare const ApiV1PackageReportListResponseSchema: import("arktype/inter
triagedAt?: number | null | undefined;
triagedBy?: string | null | undefined;
triageNote?: string | null | undefined;
actionTaken?: "none" | "quarantine" | "revoke" | null | undefined;
actionTaken?: "revoke" | "none" | "quarantine" | null | undefined;
}[];
nextCursor: string | null;
done: boolean;
@@ -834,7 +835,7 @@ export declare const ApiV1PackageReportTriageResponseSchema: import("arktype/int
packageId: string;
status: "open" | "confirmed" | "dismissed";
reportCount: number;
actionTaken?: "none" | "quarantine" | "revoke" | undefined;
actionTaken?: "revoke" | "none" | "quarantine" | undefined;
}, {}>;
export type ApiV1PackageReportTriageResponse = (typeof ApiV1PackageReportTriageResponseSchema)[inferred];
export declare const ApiV1PackageModerationStatusResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -857,7 +858,7 @@ export declare const ApiV1PackageModerationStatusResponseSchema: import("arktype
reasons: string[];
createdAt: number;
artifactKind?: "legacy-zip" | "npm-pack" | null | undefined;
moderationState?: "approved" | "quarantined" | "revoked" | null | undefined;
moderationState?: "approved" | "revoked" | "quarantined" | null | undefined;
moderationReason?: string | null | undefined;
} | null;
}, {}>;
@@ -1092,7 +1093,7 @@ export declare const ApiV1PackageModerationQueueResponseSchema: import("arktype/
reportCount: number;
reasons: string[];
artifactKind?: "legacy-zip" | "npm-pack" | null | undefined;
moderationState?: "approved" | "quarantined" | "revoked" | null | undefined;
moderationState?: "approved" | "revoked" | "quarantined" | null | undefined;
moderationReason?: string | null | undefined;
sourceRepo?: string | null | undefined;
sourceCommit?: string | null | undefined;
@@ -1106,7 +1107,7 @@ export declare const ApiV1PackageReleaseModerationResponseSchema: import("arktyp
ok: true;
packageId: string;
releaseId: string;
state: "approved" | "quarantined" | "revoked";
state: "approved" | "revoked" | "quarantined";
scanStatus: "clean" | "malicious";
}, {}>;
export type ApiV1PackageReleaseModerationResponse = (typeof ApiV1PackageReleaseModerationResponseSchema)[inferred];
+1
View File
@@ -1,6 +1,7 @@
import { type } from "arktype";
import { DocsLinks } from "./docsLinks.js";
import { CliPublishFileSchema, PublishSourceSchema } from "./schemas.js";
export const PACKAGE_TRENDING_LEADERBOARD_LIMIT = 200;
export function normalizePackageOwnerHandle(handle) {
const normalized = handle?.trim().replace(/^@+/, "").toLowerCase();
return normalized || undefined;
File diff suppressed because one or more lines are too long
+2
View File
@@ -2,6 +2,8 @@ import { type inferred, type } from "arktype";
import { DocsLinks } from "./docsLinks.js";
import { CliPublishFileSchema, PublishSourceSchema } from "./schemas.js";
export const PACKAGE_TRENDING_LEADERBOARD_LIMIT = 200;
export function normalizePackageOwnerHandle(handle: string | null | undefined) {
const normalized = handle?.trim().replace(/^@+/, "").toLowerCase();
return normalized || undefined;
@@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { buildCatalogPresentationFixtures } from "./seed-catalog-presentation";
import { DEFAULT_PUBLIC_CORPUS_FIXTURE, parseCorpusJsonl } from "./validate";
describe("catalog presentation seed", () => {
it("builds sixteen official org fixtures with featured skills and plugins", () => {
const rows = parseCorpusJsonl(readFileSync(DEFAULT_PUBLIC_CORPUS_FIXTURE, "utf8"));
const fixtures = buildCatalogPresentationFixtures(rows);
expect(fixtures).toHaveLength(16);
expect(new Set(fixtures.map((fixture) => fixture.sourceOwnerHandle)).size).toBe(16);
expect(new Set(fixtures.map((fixture) => fixture.handle)).size).toBe(16);
expect(fixtures.filter((fixture) => fixture.featured)).toHaveLength(8);
expect(fixtures.every((fixture) => fixture.skillSlug && fixture.packageName)).toBe(true);
});
});
@@ -0,0 +1,122 @@
#!/usr/bin/env bun
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { assertSeedTargetAllowed } from "../seed";
import { buildDummyOwnerPool, ownerForCorpusKey } from "./dummyOwners";
import { DEFAULT_PUBLIC_CORPUS_FIXTURE, parseCorpusJsonl, type PublicCorpusRow } from "./validate";
type Options = {
previewName: string | null;
};
const ORG_IDENTITIES = [
["catalog-atlas", "Atlas Automation"],
["catalog-northstar", "Northstar Systems"],
["catalog-relay", "Relay Labs"],
["catalog-signal-forge", "Signal Forge"],
["catalog-harbor", "Harbor AI"],
["catalog-juniper", "Juniper Works"],
["catalog-orbit", "Orbit Tools"],
["catalog-prism", "Prism Stack"],
["catalog-canvas", "Canvas Labs"],
["catalog-threadline", "Threadline"],
["catalog-waypoint", "Waypoint Systems"],
["catalog-beacon", "Beacon Works"],
["catalog-mosaic", "Mosaic AI"],
["catalog-summit", "Summit Tools"],
["catalog-fieldnote", "Fieldnote Labs"],
["catalog-lattice", "Lattice Systems"],
] as const;
export function buildCatalogPresentationFixtures(rows: PublicCorpusRow[]) {
const owners = buildDummyOwnerPool();
const rowsByOwner = new Map<
string,
{
skills: Extract<PublicCorpusRow, { kind: "skill" }>[];
plugins: Extract<PublicCorpusRow, { kind: "plugin" }>[];
}
>();
for (const row of rows) {
const key = row.kind === "skill" ? `skill:${row.slug}` : `plugin:${row.name}`;
const owner = ownerForCorpusKey(key, owners);
const owned = rowsByOwner.get(owner.handle) ?? { skills: [], plugins: [] };
if (row.kind === "skill") owned.skills.push(row);
else owned.plugins.push(row);
rowsByOwner.set(owner.handle, owned);
}
return ORG_IDENTITIES.map(([handle, displayName], index) => {
const sourceOwner = owners[index];
const owned = sourceOwner ? rowsByOwner.get(sourceOwner.handle) : undefined;
const skill = owned?.skills[0];
const plugin = owned?.plugins[0];
if (!sourceOwner || !skill || !plugin) {
throw new Error(`Public corpus cannot supply catalog presentation fixture ${displayName}`);
}
return {
sourceOwnerHandle: sourceOwner.handle,
handle,
displayName,
bio: "Synthetic official creator for local and pull request previews.",
image: `https://api.dicebear.com/9.x/shapes/svg?seed=${encodeURIComponent(handle)}`,
skillSlug: skill.slug,
packageName: plugin.name,
featured: index < 8,
};
});
}
function parseArgs(args: string[]): Options {
const options: Options = { previewName: null };
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--preview-name") {
options.previewName = readValue(args, ++index, arg);
} else if (arg.startsWith("--preview-name=")) {
options.previewName = arg.slice("--preview-name=".length);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function readValue(args: string[], index: number, flag: string) {
const value = args[index]?.trim();
if (!value) throw new Error(`Missing value for ${flag}`);
return value;
}
function main() {
const options = parseArgs(process.argv.slice(2));
assertSeedTargetAllowed(options);
const rows = parseCorpusJsonl(readFileSync(DEFAULT_PUBLIC_CORPUS_FIXTURE, "utf8"));
const orgs = buildCatalogPresentationFixtures(rows);
const targetArgs = options.previewName ? ["--preview-name", options.previewName] : ["--no-push"];
const result = spawnSync(
"bunx",
[
"convex",
"run",
...targetArgs,
"devSeed:seedCatalogPresentationFixtures",
JSON.stringify({ orgs }),
],
{ cwd: process.cwd(), env: process.env, stdio: "inherit" },
);
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
}
if (import.meta.main) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
+12
View File
@@ -12,6 +12,10 @@ describe("shared seed runner", () => {
command: "bun",
args: ["scripts/public-corpus/seed-public-corpus.ts"],
},
{
command: "bun",
args: ["scripts/public-corpus/seed-catalog-presentation.ts"],
},
{
command: "bunx",
args: ["convex", "run", "--no-push", "statsMaintenance:updateGlobalStatsAction"],
@@ -29,6 +33,14 @@ describe("shared seed runner", () => {
command: "bun",
args: ["scripts/public-corpus/seed-public-corpus.ts", "--preview-name", "feature/demo"],
},
{
command: "bun",
args: [
"scripts/public-corpus/seed-catalog-presentation.ts",
"--preview-name",
"feature/demo",
],
},
{
command: "bunx",
args: [
+4
View File
@@ -46,6 +46,10 @@ export function buildSeedSteps(options: SeedOptions): SeedStep[] {
command: "bun",
args: ["scripts/public-corpus/seed-public-corpus.ts", ...corpusTargetArgs],
},
{
command: "bun",
args: ["scripts/public-corpus/seed-catalog-presentation.ts", ...corpusTargetArgs],
},
{
command: "bunx",
args: ["convex", "run", ...convexTargetArgs, "statsMaintenance:updateGlobalStatsAction"],
+4 -1
View File
@@ -19,7 +19,10 @@ Local fixture seeding is command-driven by default:
- CLI seeding (`bun run seed:dev`) runs the same seed path manually without starting the preview and
bypasses the first-run sentinel.
- `bun run seed` is the shared seed pipeline used after local setup and by disposable PR previews.
It installs the same moderation fixtures and committed public corpus, then refreshes global stats.
It installs the same moderation fixtures and committed public corpus, creates deterministic
catalog presentation fixtures, then refreshes global stats. The presentation pass creates 16
synthetic official organizations with real corpus-backed skills and plugins; the first eight of
each type are highlighted so Featured and Official creators render in local and PR previews.
Without `--preview-name` it accepts only a local Convex deployment; remote use requires an
explicit preview name plus a Convex Preview deploy key. Vercel recreates that preview deployment
before invoking the shared seed, so the corpus import does not perform a destructive reset.
+179 -170
View File
@@ -51,6 +51,51 @@ vi.mock("../lib/packageApi", () => ({
import { HomeListingSection } from "../components/HomeListingSection";
const featuredPlugin = {
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin" as const,
channel: "community" as const,
isOfficial: false,
summary: "Runs workflows.",
icon: "https://example.com/demo-plugin.png",
createdAt: 1,
updatedAt: 2,
latestVersion: "1.0.0",
stats: { stars: 8, downloads: 120, installs: 120, versions: 1 },
};
function initialPluginListing({
items = [featuredPlugin],
pluginsFeatured = true,
skillsFeatured = true,
}: {
items?: (typeof featuredPlugin)[];
pluginsFeatured?: boolean;
skillsFeatured?: boolean;
} = {}) {
return {
kind: "plugins" as const,
tab: pluginsFeatured ? ("featured" as const) : ("popular" as const),
categorySlugs: [] as [],
fetchLimit: 20 as const,
items,
hasMore: false,
featuredAvailability: {
plugins: pluginsFeatured,
skills: skillsFeatured,
},
};
}
function renderSkillsListing() {
const result = render(
<HomeListingSection initialListing={initialPluginListing({ skillsFeatured: false })} />,
);
fireEvent.click(screen.getByRole("button", { name: "Skills" }));
return result;
}
describe("HomeListingSection", () => {
beforeEach(() => {
navigateMock.mockReset();
@@ -90,16 +135,80 @@ describe("HomeListingSection", () => {
});
});
it("renders the listing toolbar and skill cards by default", async () => {
render(<HomeListingSection />);
it("renders Featured plugins as cards by default", async () => {
render(<HomeListingSection initialListing={initialPluginListing()} />);
expect(screen.getByRole("group", { name: "Content type" })).toBeTruthy();
expect(screen.getByRole("tab", { name: "Trending" })).toBeTruthy();
const contentTypeButtons = screen
.getByRole("group", { name: "Content type" })
.querySelectorAll("button");
expect(Array.from(contentTypeButtons, (button) => button.textContent)).toEqual([
"Plugins",
"Skills",
]);
expect(screen.getByRole("button", { name: "Plugins" }).getAttribute("aria-pressed")).toBe(
"true",
);
expect(screen.getByRole("tab", { name: "Featured" }).getAttribute("aria-selected")).toBe(
"true",
);
expect(screen.getByRole("button", { name: "Grid view" }).getAttribute("aria-pressed")).toBe(
"true",
);
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([
"Featured",
"Top",
"Trending",
]);
expect(screen.queryByRole("tab", { name: "New" })).toBeNull();
expect(screen.queryByRole("tab", { name: "Verified" })).toBeNull();
expect(screen.getByText("Demo Plugin")).toBeTruthy();
expect(document.querySelector(".home-v2-listing-grid")).toBeTruthy();
expect(document.querySelector(".marketplace-icon-image")?.getAttribute("src")).toBe(
featuredPlugin.icon,
);
});
it("hides Featured and selects Top when plugins have no Featured results", () => {
render(
<HomeListingSection
initialListing={initialPluginListing({ items: [], pluginsFeatured: false })}
/>,
);
expect(screen.queryByRole("tab", { name: "Featured" })).toBeNull();
expect(screen.getByRole("tab", { name: "Top" }).getAttribute("aria-selected")).toBe("true");
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["Top", "Trending"]);
});
it("selects Featured when switching to skills that have Featured results", async () => {
render(<HomeListingSection initialListing={initialPluginListing()} />);
fireEvent.click(screen.getByRole("button", { name: "Skills" }));
expect(screen.getByRole("tab", { name: "Featured" }).getAttribute("aria-selected")).toBe(
"true",
);
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([
"Featured",
"Top",
"Trending",
]);
expect(screen.queryByRole("tab", { name: "New" })).toBeNull();
await waitFor(() => {
expect(screen.getByText("Demo Skill")).toBeTruthy();
expect(convexQueryMock).toHaveBeenCalledWith(
"skills:listPublicPageV4",
expect.objectContaining({ highlightedOnly: true }),
);
});
});
it("hides Featured and selects Top when skills have no Featured results", () => {
render(<HomeListingSection initialListing={initialPluginListing({ skillsFeatured: false })} />);
fireEvent.click(screen.getByRole("button", { name: "Skills" }));
expect(screen.queryByRole("tab", { name: "Featured" })).toBeNull();
expect(screen.getByRole("tab", { name: "Top" }).getAttribute("aria-selected")).toBe("true");
});
it("previews long skill and plugin names while retaining their full labels", async () => {
const skillName = "S".repeat(71);
const pluginName = "P".repeat(71);
@@ -135,7 +244,7 @@ describe("HomeListingSection", () => {
nextCursor: null,
});
render(<HomeListingSection />);
renderSkillsListing();
await waitFor(() => {
expect(screen.getByText(`${"S".repeat(69)}`).getAttribute("title")).toBe(skillName);
@@ -149,7 +258,7 @@ describe("HomeListingSection", () => {
});
});
it("renders the initial Skills Top listing without refetching on mount", async () => {
it("renders an initial Skills Top listing without refetching on mount", async () => {
render(
<HomeListingSection
initialListing={{
@@ -176,6 +285,10 @@ describe("HomeListingSection", () => {
},
],
hasMore: true,
featuredAvailability: {
plugins: true,
skills: true,
},
}}
/>,
);
@@ -187,10 +300,8 @@ describe("HomeListingSection", () => {
});
});
it("switches to plugins and loads plugin cards", async () => {
render(<HomeListingSection />);
fireEvent.click(screen.getByRole("button", { name: "Plugins" }));
it("loads the plugin Top tab", async () => {
render(<HomeListingSection initialListing={initialPluginListing()} />);
fireEvent.click(screen.getByRole("tab", { name: "Top" }));
await waitFor(() => {
@@ -214,7 +325,7 @@ describe("HomeListingSection", () => {
},
]);
render(<HomeListingSection />);
renderSkillsListing();
fireEvent.click(screen.getByRole("button", { name: "Search catalog" }));
expect(document.querySelector(".home-v2-listing-search.is-open")).toBeTruthy();
@@ -253,7 +364,7 @@ describe("HomeListingSection", () => {
},
]);
render(<HomeListingSection />);
renderSkillsListing();
fireEvent.click(screen.getByRole("combobox", { name: "Category" }));
fireEvent.click(screen.getByRole("option", { name: "Development" }));
@@ -292,7 +403,7 @@ describe("HomeListingSection", () => {
},
]);
render(<HomeListingSection />);
renderSkillsListing();
fireEvent.click(screen.getByRole("button", { name: "Search catalog" }));
const searchInput = await screen.findByRole("searchbox", { name: "Search skills" });
@@ -315,7 +426,7 @@ describe("HomeListingSection", () => {
});
it("renders the canonical skill and plugin category definitions", async () => {
render(<HomeListingSection />);
renderSkillsListing();
await waitFor(() => {
expect(screen.getByText("Demo Skill").textContent).toBe("Demo Skill");
@@ -360,7 +471,7 @@ describe("HomeListingSection", () => {
}),
);
render(<HomeListingSection />);
renderSkillsListing();
await waitFor(() => {
expect(screen.getByText("Skill 0")).toBeTruthy();
@@ -410,7 +521,7 @@ describe("HomeListingSection", () => {
});
});
render(<HomeListingSection />);
renderSkillsListing();
await waitFor(() => {
expect(screen.getByText("Demo Skill")).toBeTruthy();
@@ -463,7 +574,7 @@ describe("HomeListingSection", () => {
});
});
render(<HomeListingSection />);
renderSkillsListing();
await waitFor(() => {
expect(screen.getByText("Top Skill")).toBeTruthy();
@@ -485,118 +596,21 @@ describe("HomeListingSection", () => {
expect(convexQueryMock).toHaveBeenCalledTimes(2);
});
it("keeps pending and suspicious audits out of skill New", async () => {
convexQueryMock.mockResolvedValue({
page: [
{
skill: {
_id: "skills:pending",
slug: "pending-skill",
displayName: "Pending Skill",
githubScanStatus: "pending",
createdAt: 30,
updatedAt: 30,
stats: { installs: 0 },
},
ownerHandle: "builder",
},
{
skill: {
_id: "skills:moderated-suspicious",
slug: "moderated-suspicious-skill",
displayName: "Moderated Suspicious Skill",
isSuspicious: true,
createdAt: 25,
updatedAt: 25,
stats: { installs: 0 },
},
ownerHandle: "builder",
},
{
skill: {
_id: "skills:suspicious",
slug: "suspicious-skill",
displayName: "Suspicious Skill",
githubScanStatus: "suspicious",
createdAt: 20,
updatedAt: 20,
stats: { installs: 0 },
},
ownerHandle: "builder",
},
{
skill: {
_id: "skills:clean",
slug: "clean-skill",
displayName: "Clean Skill",
githubScanStatus: "clean",
createdAt: 10,
updatedAt: 10,
stats: { installs: 0 },
},
ownerHandle: "builder",
},
],
hasMore: false,
nextCursor: null,
});
render(<HomeListingSection />);
fireEvent.click(screen.getByRole("tab", { name: "New" }));
await waitFor(() => {
expect(screen.getByText("Clean Skill")).toBeTruthy();
});
expect(screen.queryByText("Pending Skill")).toBeNull();
expect(screen.queryByText("Suspicious Skill")).toBeNull();
expect(screen.queryByText("Moderated Suspicious Skill")).toBeNull();
});
it("asks the plugin catalog to exclude pending and suspicious audits from New", async () => {
render(<HomeListingSection />);
fireEvent.click(screen.getByRole("button", { name: "Plugins" }));
fireEvent.click(screen.getByRole("tab", { name: "New" }));
await waitFor(() => {
expect(fetchPluginCatalogMock).toHaveBeenLastCalledWith(
expect.objectContaining({
excludedScanStatuses: ["pending", "suspicious"],
sort: "updated",
}),
);
});
});
it("keeps pending and suspicious audits out of New search", async () => {
it("keeps Featured active for skill search", async () => {
convexActionMock.mockResolvedValue([
{
skill: {
_id: "skills:pending-search",
slug: "pending-search",
displayName: "Pending Search Skill",
githubScanStatus: "pending",
createdAt: 2,
updatedAt: 2,
stats: { installs: 0 },
},
ownerHandle: "builder",
},
{
skill: {
_id: "skills:clean-search",
slug: "clean-search",
displayName: "Clean Search Skill",
githubScanStatus: "clean",
createdAt: 1,
updatedAt: 1,
_id: "skills:featured-search",
slug: "featured-search",
displayName: "Featured Search Skill",
stats: { installs: 0 },
},
ownerHandle: "builder",
},
]);
render(<HomeListingSection />);
fireEvent.click(screen.getByRole("tab", { name: "New" }));
render(<HomeListingSection initialListing={initialPluginListing()} />);
fireEvent.click(screen.getByRole("button", { name: "Skills" }));
fireEvent.click(screen.getByRole("button", { name: "Search catalog" }));
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "search" } });
@@ -604,90 +618,85 @@ describe("HomeListingSection", () => {
expect(convexActionMock).toHaveBeenCalledWith("search:searchSkills", {
query: "search",
limit: 20,
nonSuspiciousOnly: true,
excludePendingScan: true,
highlightedOnly: true,
});
expect(screen.getByText("Clean Search Skill")).toBeTruthy();
expect(screen.getByText("Featured Search Skill")).toBeTruthy();
});
expect(screen.queryByText("Pending Search Skill")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Plugins" }));
fireEvent.click(screen.getByRole("tab", { name: "New" }));
await waitFor(() =>
expect(fetchPluginCatalogMock).toHaveBeenLastCalledWith(
expect.objectContaining({
excludedScanStatuses: ["pending", "suspicious"],
q: "search",
}),
),
);
});
it("requests official plugins from the catalog API", async () => {
it("keeps Featured active for plugin search", async () => {
render(<HomeListingSection initialListing={initialPluginListing()} />);
fireEvent.click(screen.getByRole("button", { name: "Search catalog" }));
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "search" } });
await waitFor(() => {
expect(fetchPluginCatalogMock).toHaveBeenLastCalledWith(
expect.objectContaining({
featured: true,
q: "search",
}),
);
});
});
it("requests trending plugins from the catalog API", async () => {
fetchPluginCatalogMock.mockResolvedValue({
items: [
{
name: "community-plugin",
displayName: "Community Plugin",
name: "trending-plugin",
displayName: "Trending Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
createdAt: 1,
updatedAt: 2,
stats: { stars: 1, downloads: 2, installs: 0, versions: 1 },
},
{
name: "official-plugin",
displayName: "Official Plugin",
family: "code-plugin",
channel: "official",
isOfficial: true,
createdAt: 1,
updatedAt: 2,
stats: { stars: 4, downloads: 8, installs: 0, versions: 1 },
stats: { stars: 4, downloads: 8, installs: 9, versions: 1 },
},
],
nextCursor: null,
});
render(<HomeListingSection />);
fireEvent.click(screen.getByRole("button", { name: "Plugins" }));
fireEvent.click(screen.getByRole("tab", { name: "Verified" }));
render(<HomeListingSection initialListing={initialPluginListing()} />);
fireEvent.click(screen.getByRole("tab", { name: "Trending" }));
await waitFor(() => {
expect(screen.getByText("Official Plugin").textContent).toBe("Official Plugin");
expect(screen.getByText("Trending Plugin").textContent).toBe("Trending Plugin");
});
expect(screen.queryByText("Community Plugin")).toBeNull();
const latestRequest = fetchPluginCatalogMock.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(latestRequest).toEqual(expect.objectContaining({ isOfficial: true, limit: 20 }));
expect(latestRequest).toEqual(expect.objectContaining({ sort: "trending", limit: 20 }));
});
it("reuses cached plugin tabs instead of refetching when switching back", async () => {
fetchPluginCatalogMock.mockImplementation((args: { isOfficial?: boolean }) =>
fetchPluginCatalogMock.mockImplementation((args: { sort?: string }) =>
Promise.resolve({
items: [
{
name: args.isOfficial ? "official-plugin" : "top-plugin",
displayName: args.isOfficial ? "Official Plugin" : "Top Plugin",
name: args.sort === "trending" ? "trending-plugin" : "top-plugin",
displayName: args.sort === "trending" ? "Trending Plugin" : "Top Plugin",
family: "code-plugin",
channel: args.isOfficial ? "official" : "community",
isOfficial: Boolean(args.isOfficial),
channel: "community",
isOfficial: false,
summary: "Cached plugin.",
createdAt: 1,
updatedAt: 2,
latestVersion: "1.0.0",
stats: { stars: 1, downloads: 2, installs: args.isOfficial ? 50 : 75, versions: 1 },
stats: {
stars: 1,
downloads: 2,
installs: args.sort === "trending" ? 50 : 75,
versions: 1,
},
},
],
nextCursor: null,
}),
);
render(<HomeListingSection />);
fireEvent.click(screen.getByRole("button", { name: "Plugins" }));
render(<HomeListingSection initialListing={initialPluginListing()} />);
fireEvent.click(screen.getByRole("tab", { name: "Trending" }));
await waitFor(() => {
expect(screen.getByText("Official Plugin")).toBeTruthy();
expect(screen.getByText("Trending Plugin")).toBeTruthy();
});
expect(fetchPluginCatalogMock).toHaveBeenCalledTimes(1);
@@ -698,10 +707,10 @@ describe("HomeListingSection", () => {
});
expect(fetchPluginCatalogMock).toHaveBeenCalledTimes(2);
fireEvent.click(screen.getByRole("tab", { name: "Verified" }));
fireEvent.click(screen.getByRole("tab", { name: "Trending" }));
await waitFor(() => {
expect(screen.getByText("Official Plugin")).toBeTruthy();
expect(screen.getByText("Trending Plugin")).toBeTruthy();
});
expect(fetchPluginCatalogMock).toHaveBeenCalledTimes(2);
});
@@ -744,7 +753,7 @@ describe("HomeListingSection", () => {
nextCursor: null,
});
render(<HomeListingSection />);
renderSkillsListing();
await waitFor(() => {
expect(screen.getByText("First Skill")).toBeTruthy();
@@ -788,7 +797,7 @@ describe("HomeListingSection", () => {
return Promise.resolve({ page: development, hasMore: false, nextCursor: null });
});
render(<HomeListingSection />);
renderSkillsListing();
await waitFor(() => {
expect(screen.getByText("development Skill 0")).toBeTruthy();
@@ -841,7 +850,7 @@ describe("HomeListingSection", () => {
nextCursor: null,
});
render(<HomeListingSection />);
renderSkillsListing();
await waitFor(() => {
expect(screen.getByText("Inferred Skill")).toBeTruthy();
@@ -3,14 +3,18 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const convexQueryMock = vi.fn();
const convexActionMock = vi.fn();
vi.mock("../convex/client", () => ({
convexHttp: { query: (...args: unknown[]) => convexQueryMock(...args) },
convexHttp: { action: (...args: unknown[]) => convexActionMock(...args) },
}));
vi.mock("../../convex/_generated/api", () => ({
api: { publishers: { getHomePublisherSummaries: "publishers:getHomePublisherSummaries" } },
api: {
publishers: {
getHomeOfficialCreatorSummaries: "publishers:getHomeOfficialCreatorSummaries",
},
},
}));
vi.mock("@tanstack/react-router", () => ({
@@ -18,6 +22,7 @@ vi.mock("@tanstack/react-router", () => ({
children,
className,
params,
search,
to: _to,
...props
}: {
@@ -25,11 +30,13 @@ vi.mock("@tanstack/react-router", () => ({
className?: string;
params?: { handle?: string; slug?: string };
to?: string;
search?: unknown;
[key: string]: unknown;
}) => (
<a
{...props}
className={className}
data-search={search ? JSON.stringify(search) : undefined}
href={
params?.slug ? `/${params.slug}` : params?.handle ? `/user/${params.handle}` : "/creators"
}
@@ -45,8 +52,8 @@ describe("HomePopularPublishersSection", () => {
let intersectionCallback: IntersectionObserverCallback;
beforeEach(() => {
convexQueryMock.mockReset();
convexQueryMock.mockResolvedValue(null);
convexActionMock.mockReset();
convexActionMock.mockResolvedValue([]);
vi.stubGlobal(
"IntersectionObserver",
class {
@@ -75,65 +82,95 @@ describe("HomePopularPublishersSection", () => {
});
};
it("loads all pinned publisher summaries once when the section nears the viewport", async () => {
convexQueryMock.mockResolvedValue([
it("loads the top twelve official creators once when the section nears the viewport", async () => {
convexActionMock.mockResolvedValue(
Array.from({ length: 12 }, (_, index) => ({
_id: `publishers:org-${index}`,
_creationTime: index,
handle: `org-${index}`,
displayName: `Official Org ${index}`,
kind: "org",
stats: {
skills: 2,
packages: 1,
installs: 12 - index,
downloads: 4,
stars: 5,
},
})),
);
render(<HomePopularPublishersSection />);
expect(convexActionMock).not.toHaveBeenCalled();
expect(screen.getByRole("heading", { name: "Official creators" })).toBeTruthy();
expect(screen.getByText("Explore skills and plugins from official creators.")).toBeTruthy();
expect(screen.getByRole("link", { name: "Browse creators" }).dataset.search).toBe(
'{"official":true,"kind":"orgs"}',
);
await enterPublisherSection();
await waitFor(() => expect(convexActionMock).toHaveBeenCalledTimes(1));
expect(convexActionMock).toHaveBeenCalledWith("publishers:getHomeOfficialCreatorSummaries", {
limit: 12,
});
expect(screen.getAllByRole("link", { name: /Official Org/ })).toHaveLength(12);
expect(screen.getByText("12 installs")).toBeTruthy();
expect(screen.getByText("1 install")).toBeTruthy();
expect(document.querySelectorAll(".home-v2-popular-publisher-card")).toHaveLength(12);
expect(
Array.from(document.querySelectorAll(".home-v2-popular-publisher-card"), (card) =>
card.getAttribute("aria-label"),
),
).toEqual(Array.from({ length: 12 }, (_, index) => `Official Org ${index}, @org-${index}`));
await enterPublisherSection();
expect(convexActionMock).toHaveBeenCalledTimes(1);
});
it("retries a failed official creator request", async () => {
convexActionMock.mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce([
{
_id: "publishers:openclaw",
_creationTime: 1,
handle: "openclaw",
displayName: "OpenClaw Registry",
displayName: "OpenClaw",
kind: "org",
stats: { skills: 2, packages: 1, installs: 3, downloads: 4, stars: 5 },
},
]);
render(<HomePopularPublishersSection />);
expect(convexQueryMock).not.toHaveBeenCalled();
expect(screen.getAllByText("Explore creator")).toHaveLength(10);
await enterPublisherSection();
await waitFor(() => expect(convexQueryMock).toHaveBeenCalledTimes(1));
expect(convexQueryMock).toHaveBeenCalledWith("publishers:getHomePublisherSummaries", {
handles: [
"openclaw",
"nvidia",
"steipete",
"mvanhorn",
"wscats",
"ivangdavila",
"byungkyu",
"pskoett",
"1kalin",
"spclaudehome",
],
});
expect(screen.getByRole("link", { name: "OpenClaw Registry, @openclaw" })).toBeTruthy();
expect(screen.getByText("Explore 3 items")).toBeTruthy();
await enterPublisherSection();
expect(convexQueryMock).toHaveBeenCalledTimes(1);
await waitFor(() => expect(convexActionMock).toHaveBeenCalledTimes(1));
expect(document.querySelectorAll(".home-v2-popular-publisher-card")).toHaveLength(0);
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => expect(convexActionMock).toHaveBeenCalledTimes(2));
expect(await screen.findByRole("link", { name: "OpenClaw, @openclaw" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
});
it("keeps static publisher cards when summary loading fails", async () => {
convexQueryMock.mockRejectedValue(new Error("offline"));
render(<HomePopularPublishersSection />);
await enterPublisherSection();
await waitFor(() => expect(convexQueryMock).toHaveBeenCalledTimes(1));
expect(screen.getByRole("link", { name: "OpenClaw, @openclaw" })).toBeTruthy();
expect(screen.getAllByText("Explore creator")).toHaveLength(10);
});
it("keeps creator cards clickable until the pointer actually drags", () => {
it("keeps creator cards clickable until the pointer actually drags", async () => {
convexActionMock.mockResolvedValue([
{
_id: "publishers:openclaw",
_creationTime: 1,
handle: "openclaw",
displayName: "OpenClaw",
kind: "org",
stats: { skills: 2, packages: 1, installs: 3, downloads: 4, stars: 5 },
},
]);
const setPointerCapture = vi.fn();
const hasPointerCapture = vi.fn(() => false);
const releasePointerCapture = vi.fn();
render(<HomePopularPublishersSection />);
await enterPublisherSection();
const card = screen.getByRole("link", { name: "OpenClaw, @openclaw" });
const card = await screen.findByRole("link", { name: "OpenClaw, @openclaw" });
expect(card.getAttribute("href")).toBe("/openclaw");
const viewport = document.querySelector(".home-v2-popular-publishers-viewport");
expect(viewport).toBeTruthy();
+13 -9
View File
@@ -5,22 +5,26 @@ import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
const initialListingFixture = {
kind: "skills",
tab: "popular",
kind: "plugins",
tab: "featured",
categorySlugs: [],
fetchLimit: 20,
items: [
{
skill: {
_id: "skills:initial",
slug: "initial-skill",
displayName: "Initial Skill",
stats: { installs: 10 },
},
ownerHandle: "builder",
name: "initial-plugin",
displayName: "Initial Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
createdAt: 1,
updatedAt: 2,
},
],
hasMore: false,
featuredAvailability: {
plugins: true,
skills: true,
},
};
const homeListingSectionMock = vi.fn();
+13 -1
View File
@@ -347,13 +347,25 @@ describe("restored UI design contract", () => {
expect(homeSource).toContain('className="home-v2-main oc-app-surface"');
expect(homeSource).toContain("home-v2-headline oc-hero-title");
expect(listingSource).toContain("home-v2-listing-card oc-card oc-card-interactive");
expect(listingSource).toContain("home-v2-listing-kind clawhub-segmented");
expect(listingSource).toContain("home-v2-listing-kind clawhub-segmented oc-segmented");
expect(listingSource).toContain(
"home-v2-listing-kind-btn clawhub-segmented-btn oc-segmented-item",
);
expect(listingSource).toContain("home-v2-listing-view clawhub-segmented oc-segmented");
expect(listingSource).toContain(
"home-v2-listing-view-btn clawhub-segmented-btn oc-segmented-item",
);
expect(appsSource).toContain('className="home-v2-apps-tile"');
expect(appsSource).toContain('className="home-v2-apps-workflow-header"');
expect(appsSource).not.toContain('className="home-v2-apps-workflow-header oc-card"');
expect(publishersSource).toContain(
"home-v2-popular-publisher-card oc-card oc-card-interactive",
);
expect(publishersSource).toContain("Official creators");
expect(publishersSource).toContain("Explore skills and plugins from official creators.");
expect(cssRule(css, ".home-v2-popular-publishers-track")).toContain(
"grid-template-columns: repeat(6, minmax(0, 1fr))",
);
expect(homeSource).not.toContain("BUILT BY THE COMMUNITY");
expect(homeSource).not.toContain("Unleash.");
expect(homeSource).not.toContain("Ship.");
+96 -72
View File
@@ -1,7 +1,6 @@
import { Link } from "@tanstack/react-router";
import { isPluginCategorySlug, isSkillCategorySlug } from "clawhub-schema";
import {
BadgeCheck,
Binoculars,
CloudOff,
Download,
@@ -27,16 +26,13 @@ import { api } from "../../convex/_generated/api";
import { convexHttp } from "../convex/client";
import { PLUGIN_CATEGORIES, SKILL_CATEGORIES, type BrowseCategory } from "../lib/categories";
import {
filterHomePluginsByTab as filterPluginsByTab,
filterHomeSkillsByTab as filterSkillsByTab,
fetchHomeFeaturedAvailability as fetchFeaturedAvailability,
fetchHomePluginListing as fetchPluginListing,
fetchHomeSkillListing as fetchSkillListing,
HOME_LISTING_PAGE_SIZE,
homeListingCacheKey as listingCacheKey,
isNewHomeSkillEligible as isNewSkillEligible,
itemMatchesAnyHomeCategory as itemMatchesAnyCategory,
skillMatchesAnyHomeCategory as skillMatchesAnyCategory,
sortHomeSkillEntries as sortSkillEntries,
uniqueHomePlugins as uniquePlugins,
uniqueHomeSkillEntries as uniqueSkillEntries,
type HomeListingCacheEntry,
@@ -58,15 +54,15 @@ import { BrowseResultsSkeleton } from "./skeletons/BrowseResultsSkeleton";
type ListingView = "list" | "grid";
const SKILL_LISTING_TABS: Array<{ id: ListingTab; label: string }> = [
{ id: "featured", label: "Featured" },
{ id: "popular", label: "Top" },
{ id: "trending", label: "Trending" },
{ id: "new", label: "New" },
];
const PLUGIN_LISTING_TABS: Array<{ id: ListingTab; label: string }> = [
{ id: "officials", label: "Verified" },
{ id: "featured", label: "Featured" },
{ id: "popular", label: "Top" },
{ id: "new", label: "New" },
{ id: "trending", label: "Trending" },
];
const LISTING_PAGE_SIZE = HOME_LISTING_PAGE_SIZE;
@@ -229,7 +225,13 @@ function HomeListingPluginRow({ plugin }: { plugin: PackageListItem }) {
return (
<Link to={pluginHref} className="home-v2-listing-row">
<span className="home-v2-listing-row-icon" aria-hidden="true">
<MarketplaceIcon kind="plugin" label={name} size="sm" />
<MarketplaceIcon
kind="plugin"
label={name}
imageUrl={plugin.icon}
categorySlug={plugin.categories?.[0]}
size="sm"
/>
</span>
<div className="home-v2-listing-row-body">
<div className="home-v2-listing-row-title">
@@ -304,7 +306,13 @@ function HomeListingPluginCard({ plugin }: { plugin: PackageListItem }) {
<Link to={pluginHref} className="home-v2-listing-card oc-card oc-card-interactive">
<div className="home-v2-listing-card-head">
<span className="home-v2-listing-card-icon" aria-hidden="true">
<MarketplaceIcon kind="plugin" label={name} size="sm" />
<MarketplaceIcon
kind="plugin"
label={name}
imageUrl={plugin.icon}
categorySlug={plugin.categories?.[0]}
size="sm"
/>
</span>
<div className="home-v2-listing-card-id">
<span className="home-v2-listing-card-name" title={name}>
@@ -345,11 +353,17 @@ function createInitialListingCache(initialListing: HomeListingInitialData | null
categorySlugs: initialListing.categorySlugs,
fetchLimit: initialListing.fetchLimit,
}),
{
kind: "skills",
items: initialListing.items,
hasMore: initialListing.hasMore,
},
initialListing.kind === "skills"
? {
kind: "skills",
items: initialListing.items,
hasMore: initialListing.hasMore,
}
: {
kind: "plugins",
items: initialListing.items,
hasMore: initialListing.hasMore,
},
);
return cache;
}
@@ -361,14 +375,26 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
listingCacheRef.current ??= createInitialListingCache(initialListing);
const listingCache = listingCacheRef.current;
const [kind, setKind] = useState<ListingKind>("skills");
const [tab, setTab] = useState<ListingTab>("popular");
const [view, setView] = useState<ListingView>("list");
const [kind, setKind] = useState<ListingKind>(initialListing?.kind ?? "plugins");
const [tab, setTab] = useState<ListingTab>(initialListing?.tab ?? "featured");
const [view, setView] = useState<ListingView>("grid");
const [categorySlugs, setCategorySlugs] = useState<string[]>([]);
const [visibleCount, setVisibleCount] = useState(LISTING_PAGE_SIZE);
const [fetchLimit, setFetchLimit] = useState(LISTING_PAGE_SIZE);
const [skills, setSkills] = useState<SkillPageEntry[]>(initialListing?.items ?? []);
const [plugins, setPlugins] = useState<PackageListItem[]>([]);
const [skills, setSkills] = useState<SkillPageEntry[]>(
initialListing?.kind === "skills" ? initialListing.items : [],
);
const [plugins, setPlugins] = useState<PackageListItem[]>(
initialListing?.kind === "plugins" ? initialListing.items : [],
);
const [featuredAvailability, setFeaturedAvailability] = useState<
Record<ListingKind, boolean | null>
>(
initialListing?.featuredAvailability ?? {
plugins: null,
skills: null,
},
);
const [status, setStatus] = useState<"loading" | "idle" | "error">(
initialListing ? "idle" : "loading",
);
@@ -392,20 +418,14 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
[categorySlugs, listingCategories],
);
const filteredSearchSkills = useMemo(
() => filterSkillsByTab(searchSkills, tab),
[searchSkills, tab],
const visibleTabs = (kind === "skills" ? SKILL_LISTING_TABS : PLUGIN_LISTING_TABS).filter(
(item) => item.id !== "featured" || featuredAvailability[kind] === true,
);
const filteredSearchPlugins = useMemo(
() => filterPluginsByTab(searchPlugins, tab),
[searchPlugins, tab],
);
const visibleTabs = kind === "skills" ? SKILL_LISTING_TABS : PLUGIN_LISTING_TABS;
const activeItems = isSearchMode
? kind === "skills"
? filteredSearchSkills
: filteredSearchPlugins
? searchSkills
: searchPlugins
: kind === "skills"
? skills
: plugins;
@@ -448,6 +468,26 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
return () => window.removeEventListener("keydown", onKeyDown);
}, [searchOpen, trimmedSearch]);
useEffect(() => {
if (featuredAvailability[kind] !== null) return undefined;
const controller = new AbortController();
fetchFeaturedAvailability(kind, controller.signal)
.then((available) => {
if (controller.signal.aborted) return;
setFeaturedAvailability((current) => ({ ...current, [kind]: available }));
if (!available) {
setTab((current) => (current === "featured" ? "popular" : current));
}
})
.catch(() => {
if (controller.signal.aborted) return;
setFeaturedAvailability((current) => ({ ...current, [kind]: false }));
setTab((current) => (current === "featured" ? "popular" : current));
});
return () => controller.abort();
}, [featuredAvailability, kind]);
useEffect(() => {
if (isSearchMode) return undefined;
const cacheKey = listingCacheKey({ kind, tab, categorySlugs, fetchLimit });
@@ -546,7 +586,7 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
convexHttp.action(api.search.searchSkills, {
query: trimmedSearch,
limit: fetchLimit,
...(tab === "new" ? { nonSuspiciousOnly: true, excludePendingScan: true } : {}),
highlightedOnly: tab === "featured" ? true : undefined,
...(categorySlug ? { categorySlug } : {}),
}),
),
@@ -560,17 +600,12 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
ownerHandle: hit.ownerHandle,
owner: hit.owner,
}))
.filter(
(entry) =>
skillMatchesAnyCategory(entry.skill, categorySlugs) &&
(tab !== "new" || isNewSkillEligible(entry.skill)),
),
.filter((entry) => skillMatchesAnyCategory(entry.skill, categorySlugs)),
),
);
const sortedRows = tab === "new" ? sortSkillEntries(rows, tab) : rows;
const items = sortedRows.slice(0, fetchLimit);
const items = rows.slice(0, fetchLimit);
const hasMore =
sortedRows.length > fetchLimit ||
rows.length > fetchLimit ||
results.some((hits) => (hits as SkillSearchHit[]).length >= fetchLimit);
setSearchSkills(items);
setListingHasMore(hasMore);
@@ -581,9 +616,8 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
fetchPluginCatalog({
q: trimmedSearch,
category: categorySlug ?? undefined,
isOfficial: tab === "officials" ? true : undefined,
excludedScanStatuses: tab === "new" ? ["pending", "suspicious"] : undefined,
sort: tab === "new" ? "updated" : "downloads",
featured: tab === "featured" ? true : undefined,
sort: tab === "trending" ? "trending" : "downloads",
limit: fetchLimit,
signal: controller.signal,
}),
@@ -595,12 +629,10 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
result.items.filter((item) => itemMatchesAnyCategory(item, categorySlugs)),
),
);
const sortedItems =
tab === "new" ? [...items].sort((a, b) => b.updatedAt - a.updatedAt) : items;
const hasMore = results.some(
(result) => result.nextCursor != null || result.items.length >= fetchLimit,
);
setSearchPlugins(sortedItems);
setSearchPlugins(items);
setListingHasMore(hasMore);
setSearchStatus("idle");
});
@@ -639,8 +671,8 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
setFetchLimit(LISTING_PAGE_SIZE);
}, [categorySlugs, isSearchMode, kind, tab, trimmedSearch, view]);
const visibleSkills = (isSearchMode ? filteredSearchSkills : skills).slice(0, visibleCount);
const visiblePlugins = (isSearchMode ? filteredSearchPlugins : plugins).slice(0, visibleCount);
const visibleSkills = (isSearchMode ? searchSkills : skills).slice(0, visibleCount);
const visiblePlugins = (isSearchMode ? searchPlugins : plugins).slice(0, visibleCount);
const handleSeeMore = () => {
setVisibleCount((count) => count + LISTING_PAGE_SIZE);
@@ -661,8 +693,8 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
if (nextKind === kind) return;
setKind(nextKind);
setCategorySlugs([]);
if (nextKind === "plugins") setTab("officials");
else if (tab === "officials") setTab("popular");
const nextFeaturedAvailability = featuredAvailability[nextKind];
setTab(nextFeaturedAvailability === false ? "popular" : "featured");
};
const removeCategory = (slug: string) => {
@@ -678,23 +710,13 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
<div className="home-v2-listing-controls">
<div className="home-v2-listing-toolbar">
<div
className="home-v2-listing-kind clawhub-segmented"
className="home-v2-listing-kind clawhub-segmented oc-segmented"
role="group"
aria-label="Content type"
>
<button
type="button"
className={`home-v2-listing-kind-btn clawhub-segmented-btn${
kind === "skills" ? " is-active" : ""
}`}
aria-pressed={kind === "skills"}
onClick={() => handleKindChange("skills")}
>
Skills
</button>
<button
type="button"
className={`home-v2-listing-kind-btn clawhub-segmented-btn${
className={`home-v2-listing-kind-btn clawhub-segmented-btn oc-segmented-item${
kind === "plugins" ? " is-active" : ""
}`}
aria-pressed={kind === "plugins"}
@@ -702,6 +724,16 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
>
Plugins
</button>
<button
type="button"
className={`home-v2-listing-kind-btn clawhub-segmented-btn oc-segmented-item${
kind === "skills" ? " is-active" : ""
}`}
aria-pressed={kind === "skills"}
onClick={() => handleKindChange("skills")}
>
Skills
</button>
</div>
<span className="home-v2-listing-divider" aria-hidden="true" />
@@ -717,14 +749,6 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
className={`home-v2-listing-tab${tab === item.id ? " is-active" : ""}`}
onClick={() => setTab(item.id)}
>
{item.id === "officials" ? (
<BadgeCheck
size={14}
strokeWidth={2.25}
className="home-v2-listing-tab-icon"
aria-hidden="true"
/>
) : null}
{item.label}
</button>
))}
@@ -754,13 +778,13 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
/>
<div
className="home-v2-listing-view clawhub-segmented"
className="home-v2-listing-view clawhub-segmented oc-segmented"
role="group"
aria-label="Layout"
>
<button
type="button"
className={`home-v2-listing-view-btn clawhub-segmented-btn${
className={`home-v2-listing-view-btn clawhub-segmented-btn oc-segmented-item${
view === "list" ? " is-active" : ""
}`}
aria-pressed={view === "list"}
@@ -771,7 +795,7 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
</button>
<button
type="button"
className={`home-v2-listing-view-btn clawhub-segmented-btn${
className={`home-v2-listing-view-btn clawhub-segmented-btn oc-segmented-item${
view === "grid" ? " is-active" : ""
}`}
aria-pressed={view === "grid"}
+61 -74
View File
@@ -1,56 +1,32 @@
import { Link } from "@tanstack/react-router";
import { ArrowRight } from "lucide-react";
import { type PointerEvent, useEffect, useRef, useState } from "react";
import { ArrowRight, RefreshCw } from "lucide-react";
import { type PointerEvent, useCallback, useEffect, useRef, useState } from "react";
import { api } from "../../convex/_generated/api";
import { convexHttp } from "../convex/client";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisherSummary } from "../lib/publicUser";
import { MarketplaceIcon } from "./MarketplaceIcon";
type PinnedPublisher = {
handle: string;
name: string;
kind: "org" | "user";
};
const HOME_OFFICIAL_CREATOR_LIMIT = 12;
const PINNED_PUBLISHERS: PinnedPublisher[] = [
{ handle: "openclaw", name: "OpenClaw", kind: "org" },
{ handle: "nvidia", name: "NVIDIA", kind: "org" },
{ handle: "steipete", name: "Peter Steinberger", kind: "user" },
{ handle: "mvanhorn", name: "Matt Van Horn", kind: "user" },
{ handle: "wscats", name: "enoyao", kind: "user" },
{ handle: "ivangdavila", name: "Iván", kind: "user" },
{ handle: "byungkyu", name: "byungkyu", kind: "user" },
{ handle: "pskoett", name: "pskoett", kind: "user" },
{ handle: "1kalin", name: "1kalin", kind: "user" },
{ handle: "spclaudehome", name: "spclaudehome", kind: "user" },
];
function PopularPublisherCard({
pinned,
publisher,
}: {
pinned: PinnedPublisher;
publisher?: PublicPublisherSummary;
}) {
const name = publisher?.displayName?.trim() || pinned.name;
const bio = publisher?.bio?.trim() || "Publisher on ClawHub.";
const kind = publisher?.kind ?? pinned.kind;
const itemCount = publisher ? publisher.stats.skills + publisher.stats.packages : null;
function OfficialCreatorCard({ publisher }: { publisher: PublicPublisherSummary }) {
const name = publisher.displayName.trim() || publisher.handle;
const bio = publisher.bio?.trim() || "Official creator on ClawHub.";
const installs = publisher.stats.installs;
return (
<Link
to="/$slug"
params={{ slug: pinned.handle }}
params={{ slug: publisher.handle }}
className="home-v2-popular-publisher-card oc-card oc-card-interactive"
aria-label={`${name}, @${pinned.handle}`}
aria-label={`${name}, @${publisher.handle}`}
draggable={false}
>
<div className="home-v2-popular-publisher-head">
<MarketplaceIcon
kind={kind === "org" ? "org" : "user"}
kind="org"
label={name}
imageUrl={publisher?.image ?? `https://github.com/${pinned.handle}.png`}
imageUrl={publisher.image ?? `https://github.com/${publisher.handle}.png`}
size="md"
/>
<span className="home-v2-popular-publisher-name">{name}</span>
@@ -58,9 +34,7 @@ function PopularPublisherCard({
<div className="home-v2-popular-publisher-copy">
<p className="home-v2-popular-publisher-bio">{bio}</p>
<span className="home-v2-popular-publisher-stats">
{itemCount === null
? "Explore creator"
: `Explore ${formatCompactStat(itemCount)} ${itemCount === 1 ? "item" : "items"}`}
{formatCompactStat(installs)} {installs === 1 ? "install" : "installs"}
<ArrowRight size={13} aria-hidden="true" />
</span>
</div>
@@ -73,35 +47,40 @@ export function HomePopularPublishersSection() {
const dragRef = useRef({ pointerId: -1, startX: 0, scrollLeft: 0, moved: false });
const [dragging, setDragging] = useState(false);
const requestedPublishersRef = useRef(false);
const [publishersByHandle, setPublishersByHandle] = useState<
Record<string, PublicPublisherSummary>
>({});
const mountedRef = useRef(true);
const [publishers, setPublishers] = useState<PublicPublisherSummary[]>([]);
const [loadFailed, setLoadFailed] = useState(false);
useEffect(() => {
let cancelled = false;
const hydratePublishers = async () => {
if (requestedPublishersRef.current) return;
requestedPublishersRef.current = true;
try {
const publishers = (await convexHttp.query(api.publishers.getHomePublisherSummaries, {
handles: PINNED_PUBLISHERS.map((publisher) => publisher.handle),
})) as PublicPublisherSummary[];
if (cancelled) return;
setPublishersByHandle(
Object.fromEntries(publishers.map((publisher) => [publisher.handle, publisher])),
);
} catch {
// Static card metadata remains usable when summaries cannot be loaded.
}
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const hydratePublishers = useCallback(async () => {
if (requestedPublishersRef.current) return;
requestedPublishersRef.current = true;
setLoadFailed(false);
try {
const result = (await convexHttp.action(api.publishers.getHomeOfficialCreatorSummaries, {
limit: HOME_OFFICIAL_CREATOR_LIMIT,
})) as PublicPublisherSummary[];
if (!mountedRef.current) return;
setPublishers(result);
} catch {
requestedPublishersRef.current = false;
if (!mountedRef.current) return;
setPublishers([]);
setLoadFailed(true);
}
}, []);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport || typeof IntersectionObserver === "undefined") {
void hydratePublishers();
return () => {
cancelled = true;
};
return () => {};
}
const observer = new IntersectionObserver(
@@ -113,12 +92,10 @@ export function HomePopularPublishersSection() {
{ rootMargin: "600px 0px" },
);
observer.observe(viewport);
return () => {
cancelled = true;
observer.disconnect();
};
}, []);
}, [hydratePublishers]);
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (event.pointerType !== "mouse" || event.button !== 0) return;
@@ -158,16 +135,20 @@ export function HomePopularPublishersSection() {
return (
<section
className="home-v2-popular-publishers oc-section"
aria-labelledby="popular-publishers-title"
aria-labelledby="official-creators-title"
>
<header className="home-v2-popular-publishers-header oc-section-header">
<div className="home-v2-popular-publishers-heading oc-section-heading">
<h2 id="popular-publishers-title" className="oc-section-title">
Popular creators
<h2 id="official-creators-title" className="oc-section-title">
Official creators
</h2>
<p className="oc-section-copy">Explore skills and plugins from standout builders.</p>
<p className="oc-section-copy">Explore skills and plugins from official creators.</p>
</div>
<Link to="/creators" className="home-v2-popular-publishers-link oc-action oc-action-ghost">
<Link
to="/creators"
search={{ official: true, kind: "orgs" }}
className="home-v2-popular-publishers-link oc-action oc-action-ghost"
>
Browse creators <ArrowRight size={14} aria-hidden="true" />
</Link>
</header>
@@ -187,14 +168,20 @@ export function HomePopularPublishersSection() {
}}
>
<div className="home-v2-popular-publishers-track">
{PINNED_PUBLISHERS.map((publisher) => (
<PopularPublisherCard
key={publisher.handle}
pinned={publisher}
publisher={publishersByHandle[publisher.handle]}
/>
{publishers.map((publisher) => (
<OfficialCreatorCard key={publisher._id} publisher={publisher} />
))}
</div>
{loadFailed ? (
<button
type="button"
className="home-v2-popular-publishers-retry oc-action oc-action-ghost"
onClick={() => void hydratePublishers()}
>
<RefreshCw size={14} aria-hidden="true" />
Retry
</button>
) : null}
</div>
</section>
);
+241
View File
@@ -0,0 +1,241 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const convexQueryMock = vi.fn();
const fetchPluginCatalogMock = vi.fn();
vi.mock("../convex/client", () => ({
convexHttp: {
query: (...args: unknown[]) => convexQueryMock(...args),
},
}));
vi.mock("../../convex/_generated/api", () => ({
api: {
skills: {
listPublicPageV4: "skills:listPublicPageV4",
listPublicTrendingPage: "skills:listPublicTrendingPage",
},
},
}));
vi.mock("./packageApi", () => ({
fetchPluginCatalog: (...args: unknown[]) => fetchPluginCatalogMock(...args),
}));
import {
fetchHomeFeaturedAvailability,
fetchHomePluginListing,
fetchHomeSkillListing,
fetchInitialHomeListing,
HOME_LISTING_PAGE_SIZE,
} from "./homeListingData";
const featuredPlugin = {
name: "featured-plugin",
displayName: "Featured Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
createdAt: 1,
updatedAt: 2,
};
describe("homeListingData", () => {
beforeEach(() => {
convexQueryMock.mockReset();
fetchPluginCatalogMock.mockReset();
convexQueryMock.mockResolvedValue({
page: [
{
skill: {
_id: "skills:featured",
slug: "featured-skill",
displayName: "Featured Skill",
stats: { downloads: 10 },
},
},
],
hasMore: false,
nextCursor: null,
});
});
it("loads Featured plugins as the initial catalog when they exist", async () => {
fetchPluginCatalogMock.mockResolvedValue({
items: [featuredPlugin],
nextCursor: null,
});
await expect(fetchInitialHomeListing()).resolves.toEqual({
kind: "plugins",
tab: "featured",
categorySlugs: [],
fetchLimit: HOME_LISTING_PAGE_SIZE,
items: [featuredPlugin],
hasMore: false,
featuredAvailability: {
plugins: true,
skills: true,
},
});
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
expect.objectContaining({ featured: true, limit: HOME_LISTING_PAGE_SIZE }),
);
});
it("falls back to Top plugins when no Featured plugins exist", async () => {
fetchPluginCatalogMock
.mockResolvedValueOnce({ items: [], nextCursor: null })
.mockResolvedValueOnce({
items: [{ ...featuredPlugin, name: "top-plugin" }],
nextCursor: null,
});
const result = await fetchInitialHomeListing();
expect(result.kind).toBe("plugins");
expect(result.tab).toBe("popular");
expect(result.featuredAvailability.plugins).toBe(false);
expect(fetchPluginCatalogMock).toHaveBeenLastCalledWith(
expect.objectContaining({ sort: "downloads", featured: undefined }),
);
});
it("uses the highlighted browse path for Featured skills", async () => {
await fetchHomeSkillListing("featured", [], HOME_LISTING_PAGE_SIZE);
expect(convexQueryMock).toHaveBeenCalledWith(
"skills:listPublicPageV4",
expect.objectContaining({
highlightedOnly: true,
numItems: 200,
sort: "downloads",
}),
);
});
it("uses a one-item request when probing Featured skill availability", async () => {
await expect(fetchHomeFeaturedAvailability("skills")).resolves.toBe(true);
expect(convexQueryMock).toHaveBeenCalledWith(
"skills:listPublicPageV4",
expect.objectContaining({
highlightedOnly: true,
numItems: 1,
}),
);
});
it("preserves global Trending order while filtering multiple plugin categories", async () => {
fetchPluginCatalogMock
.mockResolvedValueOnce({
items: [
{ ...featuredPlugin, name: "unmatched-first", categories: ["productivity"] },
{ ...featuredPlugin, name: "security-second", categories: ["security"] },
{ ...featuredPlugin, name: "development-third", categories: ["development"] },
],
nextCursor: "page-2",
})
.mockResolvedValueOnce({
items: [
{ ...featuredPlugin, name: "security-fourth", categories: ["security"] },
{ ...featuredPlugin, name: "unmatched-fifth", categories: ["productivity"] },
],
nextCursor: null,
});
const result = await fetchHomePluginListing("trending", ["development", "security"], 3);
expect(result.items.map((item) => item.name)).toEqual([
"security-second",
"development-third",
"security-fourth",
]);
expect(fetchPluginCatalogMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
category: undefined,
sort: "trending",
limit: 100,
}),
);
expect(fetchPluginCatalogMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
category: undefined,
cursor: "page-2",
sort: "trending",
}),
);
});
it("uses the API category filter for a single Trending plugin category", async () => {
fetchPluginCatalogMock.mockResolvedValue({
items: [{ ...featuredPlugin, name: "security-plugin", categories: ["security"] }],
nextCursor: null,
});
await fetchHomePluginListing("trending", ["security"], 3);
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
expect.objectContaining({
category: "security",
sort: "trending",
limit: 3,
}),
);
});
it("filters the complete bounded Trending leaderboard for multiple categories", async () => {
fetchPluginCatalogMock
.mockResolvedValueOnce({
items: Array.from({ length: 100 }, (_, index) => ({
...featuredPlugin,
name: `unmatched-${index}`,
categories: ["other"],
})),
nextCursor: "page-2",
})
.mockResolvedValueOnce({
items: [
...Array.from({ length: 99 }, (_, index) => ({
...featuredPlugin,
name: `unmatched-${index + 100}`,
categories: ["other"],
})),
{ ...featuredPlugin, name: "security-last", categories: ["security"] },
],
nextCursor: null,
});
const result = await fetchHomePluginListing("trending", ["development", "security"], 20);
expect(result.items.map((item) => item.name)).toEqual(["security-last"]);
expect(result.hasMore).toBe(false);
expect(fetchPluginCatalogMock).toHaveBeenCalledTimes(2);
});
it("fails closed if the Trending API exceeds its shared leaderboard contract", async () => {
fetchPluginCatalogMock
.mockResolvedValueOnce({
items: Array.from({ length: 100 }, (_, index) => ({
...featuredPlugin,
name: `unmatched-${index}`,
categories: ["other"],
})),
nextCursor: "page-2",
})
.mockResolvedValueOnce({
items: Array.from({ length: 100 }, (_, index) => ({
...featuredPlugin,
name: `unmatched-${index + 100}`,
categories: ["other"],
})),
nextCursor: "unexpected-page-3",
});
await expect(
fetchHomePluginListing("trending", ["development", "security"], 20),
).rejects.toThrow("exceeded 200-item contract");
});
});
+90 -61
View File
@@ -1,12 +1,12 @@
import { PACKAGE_TRENDING_LEADERBOARD_LIMIT } from "clawhub-schema";
import { api } from "../../convex/_generated/api";
import { convexHttp } from "../convex/client";
import { isSkillOfficial } from "./badges";
import { getSkillCategoriesForSkill } from "./categories";
import { fetchPluginCatalog, type PackageListItem } from "./packageApi";
import type { PublicSkill, PublicUser } from "./publicUser";
export type HomeListingKind = "skills" | "plugins";
export type HomeListingTab = "popular" | "trending" | "officials" | "new";
export type HomeListingTab = "featured" | "popular" | "trending";
export type HomeSkillListingEntry = {
skill: PublicSkill;
@@ -18,18 +18,29 @@ export type HomeListingCacheEntry =
| { kind: "skills"; items: HomeSkillListingEntry[]; hasMore: boolean }
| { kind: "plugins"; items: PackageListItem[]; hasMore: boolean };
export type HomeListingInitialData = {
kind: "skills";
tab: "popular";
type HomeListingInitialDataBase = {
tab: HomeListingTab;
categorySlugs: [];
fetchLimit: typeof HOME_LISTING_PAGE_SIZE;
items: HomeSkillListingEntry[];
hasMore: boolean;
featuredAvailability: Record<HomeListingKind, boolean>;
};
export type HomeListingInitialData =
| (HomeListingInitialDataBase & {
kind: "skills";
items: HomeSkillListingEntry[];
})
| (HomeListingInitialDataBase & {
kind: "plugins";
items: PackageListItem[];
});
export const HOME_LISTING_PAGE_SIZE = 20;
const PLUGIN_CATALOG_PAGE_LIMIT = 100;
// Highlighted skill responses are cursorless, so request the backend's full public maximum.
const FEATURED_SKILL_LIST_LIMIT = 200;
export function homeListingCacheKey({
kind,
@@ -45,28 +56,6 @@ export function homeListingCacheKey({
return ["listing", kind, tab, categoryCacheKey(categorySlugs), fetchLimit].join(":");
}
export function filterHomeSkillsByTab(entries: HomeSkillListingEntry[], tab: HomeListingTab) {
if (tab === "officials") {
return entries.filter((entry) => isSkillOfficial(entry.skill));
}
return entries;
}
export function filterHomePluginsByTab(items: PackageListItem[], tab: HomeListingTab) {
if (tab === "officials") {
return items.filter((item) => item.isOfficial);
}
return items;
}
export function isNewHomeSkillEligible(skill: PublicSkill) {
return (
!skill.isSuspicious &&
skill.githubScanStatus !== "pending" &&
skill.githubScanStatus !== "suspicious"
);
}
export function itemMatchesAnyHomeCategory(
item: { categories?: readonly string[] | null },
categorySlugs: readonly string[],
@@ -98,14 +87,8 @@ export function uniqueHomePlugins(items: PackageListItem[]) {
return [...byName.values()];
}
export function sortHomeSkillEntries(entries: HomeSkillListingEntry[], tab: HomeListingTab) {
function sortHomeSkillEntries(entries: HomeSkillListingEntry[]) {
return [...entries].sort((left, right) => {
if (tab === "new") {
return (
(right.skill.updatedAt ?? right.skill.createdAt ?? right.skill._creationTime ?? 0) -
(left.skill.updatedAt ?? left.skill.createdAt ?? left.skill._creationTime ?? 0)
);
}
return (right.skill.stats?.downloads ?? 0) - (left.skill.stats?.downloads ?? 0);
});
}
@@ -129,6 +112,7 @@ export async function fetchHomeSkillListing(
};
}
const requestLimit = tab === "featured" ? FEATURED_SKILL_LIST_LIMIT : numItems;
const categoriesToFetch = categorySlugs.length > 0 ? categorySlugs : [null];
const results = await Promise.all(
categoriesToFetch.map(async (categorySlug) => {
@@ -136,21 +120,19 @@ export async function fetchHomeSkillListing(
let cursor: string | null | undefined;
let hasMore = false;
while (page.length < numItems) {
while (page.length < requestLimit) {
const result = await convexHttp.query(api.skills.listPublicPageV4, {
cursor: cursor ?? undefined,
numItems: numItems - page.length,
sort: tab === "new" ? "newest" : "downloads",
numItems: requestLimit - page.length,
sort: "downloads",
dir: "desc",
officialFirst: tab === "officials" ? true : undefined,
highlightedOnly: tab === "featured" ? true : undefined,
categorySlug: categorySlug ?? undefined,
});
if (Array.isArray(result)) break;
const resultPage = ((result as { page?: HomeSkillListingEntry[] }).page ?? []).filter(
(entry) =>
skillMatchesAnyHomeCategory(entry.skill, categorySlugs) &&
(tab !== "new" || isNewHomeSkillEligible(entry.skill)),
(entry) => skillMatchesAnyHomeCategory(entry.skill, categorySlugs),
);
page.push(...resultPage);
@@ -164,11 +146,9 @@ export async function fetchHomeSkillListing(
}),
);
const pages = results.flatMap((result) => result.page);
const sorted = sortHomeSkillEntries(
filterHomeSkillsByTab(uniqueHomeSkillEntries(pages), tab),
tab,
);
const hasMore = sorted.length > numItems || results.some((result) => result.hasMore);
const sorted = sortHomeSkillEntries(uniqueHomeSkillEntries(pages));
const hasMore =
sorted.length > numItems || (tab !== "featured" && results.some((result) => result.hasMore));
const page = sorted.slice(0, numItems);
return { page, hasMore };
}
@@ -179,29 +159,47 @@ export async function fetchHomePluginListing(
limit: number,
signal?: AbortSignal,
) {
const openClawOfficials = tab === "officials";
const categoriesToFetch = categorySlugs.length > 0 ? categorySlugs : [null];
const featured = tab === "featured";
const trending = tab === "trending";
const usesGlobalTrendingFilter = trending && categorySlugs.length > 1;
const categoriesToFetch = usesGlobalTrendingFilter
? [null]
: categorySlugs.length > 0
? categorySlugs
: [null];
const results = await Promise.all(
categoriesToFetch.map(async (categorySlug) => {
const items: PackageListItem[] = [];
let cursor: string | null | undefined;
let hasMore = false;
let trendingCandidatesScanned = 0;
while (items.length < limit) {
const result = await fetchPluginCatalog({
category: categorySlug ?? undefined,
cursor: cursor ?? undefined,
isOfficial: openClawOfficials ? true : undefined,
excludedScanStatuses: tab === "new" ? ["pending", "suspicious"] : undefined,
sort: tab === "new" ? "updated" : "downloads",
limit: Math.min(limit - items.length, PLUGIN_CATALOG_PAGE_LIMIT),
featured: featured ? true : undefined,
sort: trending ? "trending" : "downloads",
limit: usesGlobalTrendingFilter
? PLUGIN_CATALOG_PAGE_LIMIT
: Math.min(limit - items.length, PLUGIN_CATALOG_PAGE_LIMIT),
signal,
});
trendingCandidatesScanned += result.items.length;
items.push(
...result.items.filter((item) => itemMatchesAnyHomeCategory(item, categorySlugs)),
);
hasMore = result.nextCursor != null;
if (
usesGlobalTrendingFilter &&
trendingCandidatesScanned >= PACKAGE_TRENDING_LEADERBOARD_LIMIT &&
result.nextCursor
) {
throw new Error(
`Trending plugin feed exceeded ${PACKAGE_TRENDING_LEADERBOARD_LIMIT}-item contract`,
);
}
if (!result.nextCursor || result.nextCursor === cursor) break;
cursor = result.nextCursor;
}
@@ -210,10 +208,7 @@ export async function fetchHomePluginListing(
}),
);
let items = uniqueHomePlugins(results.flatMap((result) => result.items));
items = filterHomePluginsByTab(items, tab);
if (tab === "new") {
items.sort((a, b) => b.updatedAt - a.updatedAt);
} else if (tab === "popular" || openClawOfficials) {
if (tab === "popular" || featured) {
items.sort((a, b) => (b.stats?.downloads ?? 0) - (a.stats?.downloads ?? 0));
}
const page = items.slice(0, limit);
@@ -223,15 +218,49 @@ export async function fetchHomePluginListing(
};
}
export async function fetchHomeFeaturedAvailability(kind: HomeListingKind, signal?: AbortSignal) {
if (kind === "skills") {
const result = await convexHttp.query(api.skills.listPublicPageV4, {
numItems: 1,
sort: "downloads",
dir: "desc",
highlightedOnly: true,
});
return (
!Array.isArray(result) &&
((result as { page?: HomeSkillListingEntry[] }).page?.length ?? 0) > 0
);
}
const result = await fetchPluginCatalog({
featured: true,
sort: "downloads",
limit: 1,
signal,
});
return result.items.length > 0;
}
export async function fetchInitialHomeListing(): Promise<HomeListingInitialData> {
const result = await fetchHomeSkillListing("popular", [], HOME_LISTING_PAGE_SIZE);
const [featuredPlugins, hasFeaturedSkills] = await Promise.all([
fetchHomePluginListing("featured", [], HOME_LISTING_PAGE_SIZE),
fetchHomeFeaturedAvailability("skills").catch(() => false),
]);
const hasFeaturedPlugins = featuredPlugins.items.length > 0;
const result = hasFeaturedPlugins
? featuredPlugins
: await fetchHomePluginListing("popular", [], HOME_LISTING_PAGE_SIZE);
return {
kind: "skills",
tab: "popular",
kind: "plugins",
tab: hasFeaturedPlugins ? "featured" : "popular",
categorySlugs: [],
fetchLimit: HOME_LISTING_PAGE_SIZE,
items: result.page,
items: result.items,
hasMore: result.hasMore,
featuredAvailability: {
plugins: hasFeaturedPlugins,
skills: hasFeaturedSkills,
},
};
}
+23 -15
View File
@@ -26079,20 +26079,26 @@ a.search-empty-action {
}
.home-v2-popular-publishers-track {
display: flex;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
width: 100%;
min-width: 100%;
padding: 4px 2px 12px;
}
.home-v2-popular-publishers-retry {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 36px;
}
.home-v2-popular-publisher-card {
display: grid;
grid-template-rows: auto 1fr;
flex: 0 0 calc((100% - 40px) / 5);
width: calc((100% - 40px) / 5);
min-width: 0;
min-height: 158px;
min-height: 150px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--hv2-border-strong) 88%, transparent);
border-radius: 10px;
@@ -26118,14 +26124,14 @@ a.search-empty-action {
.home-v2-popular-publisher-head {
display: flex;
align-items: center;
gap: 12px;
gap: 8px;
min-width: 0;
padding: 14px 14px 10px;
padding: 12px 12px 8px;
}
.home-v2-popular-publisher-card .marketplace-icon {
width: 42px;
height: 42px;
width: 34px;
height: 34px;
border-radius: var(--oc-radius-inset);
background: rgb(22 22 24);
border-color: var(--hv2-border-strong);
@@ -26157,7 +26163,7 @@ a.search-empty-action {
flex-direction: column;
gap: 10px;
min-width: 0;
padding: 10px 14px 14px;
padding: 8px 12px 12px;
}
.home-v2-popular-publisher-bio {
@@ -26184,9 +26190,12 @@ a.search-empty-action {
}
@media (max-width: 1024px) {
.home-v2-popular-publisher-card {
flex-basis: min(240px, 42vw);
width: min(240px, 42vw);
.home-v2-popular-publishers-track {
grid-auto-flow: column;
grid-auto-columns: min(240px, 42vw);
grid-template-columns: none;
grid-template-rows: repeat(2, minmax(150px, auto));
width: max-content;
}
}
@@ -26202,9 +26211,8 @@ a.search-empty-action {
gap: 10px;
}
.home-v2-popular-publisher-card {
flex-basis: min(220px, 76vw);
width: min(220px, 76vw);
.home-v2-popular-publishers-track {
grid-auto-columns: min(220px, 76vw);
}
}