feat: align skills.sh catalog presentation (#3370)

* feat: align skills.sh catalog presentation

* refactor: share skill detail shell with skills.sh

* test: seed skills.sh route fixtures locally

* refactor: share full skill detail page view

* feat: refine skills.sh detail presentation

* feat: refine skills.sh detail metadata

* fix: refine skills.sh detail spacing
This commit is contained in:
Patrick Erichsen
2026-08-01 16:46:48 -07:00
committed by GitHub
parent a643b75eca
commit 0fb07e5b99
25 changed files with 2078 additions and 1244 deletions
+39 -3
View File
@@ -176,7 +176,7 @@ function seedSkillArgs(storageId: string) {
}
describe("devSeed local fixtures", () => {
it("idempotently seeds an activated external row for local canonical search proof", async () => {
it("idempotently seeds an installable skills.sh route for local browser proof", async () => {
const { db, tables } = createDb();
await seedCanonicalSearchFixtureHandler(createMutationCtx(db) as never, {});
@@ -184,14 +184,50 @@ describe("devSeed local fixtures", () => {
expect(tables.skillsShMirrorRuns).toHaveLength(1);
expect(tables.skillsShMirrorDigests).toHaveLength(1);
expect(tables.skillsShMirrorDetails).toHaveLength(1);
expect(tables.skillsShCatalogControls).toHaveLength(1);
expect(tables.skillsShCatalogControls?.[0]).toEqual(
expect.objectContaining({
key: "global",
mode: "fixture",
mirrorPublicVisibilityEnabled: true,
writesEnabled: false,
scanPlanningEnabled: false,
scanAdmissionEnabled: false,
}),
);
expect(tables.skillsShMirrorDigests?.[0]).toEqual(
expect.objectContaining({
externalId: "acme/skills/risk-auditor",
searchSummary: "Audit agent workflows for security and operational risk.",
externalId: "doany-skills/skills/reddit-automation",
owner: "doany-skills",
repo: "skills",
slug: "reddit-automation",
displayName: "Reddit Automation",
upstreamInstalls: 202_996,
active: true,
publicVisible: true,
installable: true,
sourceFreshnessStatus: "observed-only",
detailStatus: "available",
githubPath: "reddit-automation",
githubCommit: "6875ced8582825395c976099fcc6a00734bb09b1",
sourceContentHash: "278abced163b5721c6fec6996f73d521c8901b905b4b2fca45757d1ff0ebbfc6",
}),
);
expect(tables.skillsShMirrorDetails?.[0]).toEqual(
expect.objectContaining({
externalId: "doany-skills/skills/reddit-automation",
contentKind: "skill-md",
path: "SKILL.md",
truncated: false,
sourceContentHash: "278abced163b5721c6fec6996f73d521c8901b905b4b2fca45757d1ff0ebbfc6",
}),
);
expect(tables.skillsShMirrorDetails?.[0]?.content).toContain("# Reddit Automation");
expect(tables.skillsShMirrorRuns?.[0]).toEqual(
expect.objectContaining({
status: "completed",
counts: expect.objectContaining({ scansPlanned: 0, scansAdmitted: 0 }),
}),
);
});
+42
View File
@@ -0,0 +1,42 @@
/// <reference types="vite/client" />
import { convexTest } from "convex-test";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { api, internal } from "./_generated/api";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
beforeEach(() => {
vi.stubEnv("CLAWHUB_ENV", "local");
vi.stubEnv("CLAWHUB_SKILLS_SH_ROLLOUT_MODE", "test");
});
afterEach(() => vi.unstubAllEnvs());
it("serves the seeded skills.sh detail through the public local route", async () => {
const t = convexTest(schema, modules);
await t.mutation(internal.devSeed.seedCanonicalSearchFixture, {});
await expect(
t.query(api.skillsShMirrorPublic.getByRoute, {
owner: "doany-skills",
repo: "skills",
slug: "reddit-automation",
}),
).resolves.toMatchObject({
kind: "external",
entry: {
externalId: "doany-skills/skills/reddit-automation",
displayName: "Reddit Automation",
upstreamInstalls: 202_996,
githubPath: "reddit-automation",
githubCommit: "6875ced8582825395c976099fcc6a00734bb09b1",
content: {
kind: "skill-md",
path: "SKILL.md",
truncated: false,
},
},
});
});
+139 -34
View File
@@ -929,21 +929,85 @@ export const seedTestFixtures: ReturnType<typeof internalAction> = internalActio
},
});
const LOCAL_CANONICAL_SEARCH_EXTERNAL_ID = "acme/skills/risk-auditor";
const LOCAL_SKILLS_SH_EXTERNAL_ID = "doany-skills/skills/reddit-automation";
const LOCAL_SKILLS_SH_SNAPSHOT_ID = "local-skills-sh-route-v1";
const LOCAL_SKILLS_SH_COMMIT = "6875ced8582825395c976099fcc6a00734bb09b1";
const LOCAL_SKILLS_SH_CONTENT_HASH =
"278abced163b5721c6fec6996f73d521c8901b905b4b2fca45757d1ff0ebbfc6";
const LOCAL_SKILLS_SH_CONTENT = `---
name: reddit-automation
displayName: Reddit Automation
description: Find relevant Reddit conversations and draft genuinely useful, disclosed replies.
---
/** Explicit local proof fixture; intentionally not part of shared Test seeding. */
# Reddit Automation
Find people on Reddit who genuinely need what you make, then draft a useful response that
honestly discloses who you are. Keep a human in the loop to review and post every reply.
## When to use
- Find Reddit conversations relevant to a product.
- Draft a helpful response for a thread the user provides.
- Turn a set of research notes into replies for human review.
## Guardrails
- Never auto-post or pretend a draft was published.
- Never invent posts, quotes, or product facts.
- Respect each community's self-promotion rules.
`;
/** Explicit local/PR-preview fixture; intentionally not part of shared Test seeding. */
export const seedCanonicalSearchFixture = internalMutation({
args: {},
handler: async (ctx) => {
const now = Date.now();
const existingControl = await ctx.db
.query("skillsShCatalogControls")
.withIndex("by_key", (q) => q.eq("key", "global"))
.unique();
const control = {
key: "global" as const,
mode: "fixture" as const,
discoveryEnabled: false,
writesEnabled: false,
scanPlanningEnabled: false,
scanAdmissionEnabled: false,
publicVisibilityEnabled: false,
mirrorPublicVisibilityEnabled: true,
paused: false,
maxEntriesPerRun: 1,
maxEntriesPerBatch: 1,
maxWritesPerBatch: 1,
maxPlannedScans: 0,
maxScanAdmissionsPerBatch: 0,
maxScanAdmissionsPerRun: 0,
maxScanAdmissionsPerDay: 0,
maxCatalogQueued: 0,
maxCatalogInFlight: 0,
maxNativeQueued: 0,
maxNativeInFlight: 0,
realScanAllowlist: [],
updatedBy: "local-dev-seed",
reason: "Expose the local skills.sh browser fixture without enabling imports or scans.",
updatedAt: now,
};
if (existingControl) {
await ctx.db.patch(existingControl._id, control);
} else {
await ctx.db.insert("skillsShCatalogControls", control);
}
const existing = await ctx.db
.query("skillsShMirrorDigests")
.withIndex("by_external_id", (q) => q.eq("externalId", LOCAL_CANONICAL_SEARCH_EXTERNAL_ID))
.withIndex("by_external_id", (q) => q.eq("externalId", LOCAL_SKILLS_SH_EXTERNAL_ID))
.unique();
const runId =
existing?.lastObservedRunId ??
(await ctx.db.insert("skillsShMirrorRuns", {
snapshotId: "local-canonical-search-v1",
snapshotId: LOCAL_SKILLS_SH_SNAPSHOT_ID,
sourceView: "leaderboard",
status: "completed",
sourceTotal: 1,
sourcePageSize: 1,
@@ -959,10 +1023,10 @@ export const seedCanonicalSearchFixture = internalMutation({
quarantined: 0,
quarantinedPreserved: 0,
conflicts: 0,
detailsInserted: 0,
detailsInserted: 1,
detailsUpdated: 0,
detailsUnchanged: 0,
detailsMissing: 1,
detailsMissing: 0,
detailsTruncated: 0,
tombstoned: 0,
reactivated: 0,
@@ -977,42 +1041,53 @@ export const seedCanonicalSearchFixture = internalMutation({
sourceBytes: 0,
},
actor: "local-dev-seed",
reason: "Reusable local canonical mixed-search browser proof fixture.",
reason: "Reusable local skills.sh route browser proof fixture.",
startedAt: now,
completedAt: now,
updatedAt: now,
}));
const digest = {
externalId: LOCAL_CANONICAL_SEARCH_EXTERNAL_ID,
externalId: LOCAL_SKILLS_SH_EXTERNAL_ID,
sourceType: "github" as const,
upstreamSourceType: "github",
owner: "acme",
owner: "doany-skills",
repo: "skills",
slug: "risk-auditor",
normalizedSlug: "risk auditor",
normalizedSlugFirstToken: "risk",
displayName: "Risk Auditor",
normalizedDisplayName: "risk auditor",
normalizedDisplayNameFirstToken: "risk",
searchSummary: "Audit agent workflows for security and operational risk.",
slug: "reddit-automation",
normalizedSlug: "reddit automation",
normalizedSlugFirstToken: "reddit",
displayName: "Reddit Automation",
normalizedDisplayName: "reddit automation",
normalizedDisplayNameFirstToken: "reddit",
searchSummary:
"Find relevant Reddit conversations and draft genuinely useful, disclosed replies.",
searchText:
"Risk Auditor risk-auditor acme skills security risk-management security-audit Audit agent workflows for security and operational risk.",
sourceUrl: "https://skills.sh/acme/skills/risk-auditor",
canonicalRepoUrl: "https://github.com/acme/skills",
githubPath: "skills/risk-auditor",
githubCommit: "0000000000000000000000000000000000000000",
upstreamInstalls: 9_000_000,
"Reddit Automation reddit-automation doany-skills skills automation reddit marketing Find relevant Reddit conversations and draft genuinely useful disclosed replies.",
sourceUrl: `https://www.skills.sh/${LOCAL_SKILLS_SH_EXTERNAL_ID}`,
canonicalRepoUrl: "https://github.com/doany-skills/skills",
githubPath: "reddit-automation",
githubCommit: LOCAL_SKILLS_SH_COMMIT,
sourceContentHash: LOCAL_SKILLS_SH_CONTENT_HASH,
upstreamInstalls: 202_996,
upstreamScanners: {
genAgentTrustHub: { status: "unavailable" },
socket: { status: "unavailable" },
snyk: { status: "unavailable" },
genAgentTrustHub: {
status: "pass",
sourceUrl: `https://www.skills.sh/${LOCAL_SKILLS_SH_EXTERNAL_ID}/security/agent-trust-hub`,
},
socket: {
status: "warn",
sourceUrl: `https://www.skills.sh/${LOCAL_SKILLS_SH_EXTERNAL_ID}/security/socket`,
},
snyk: {
status: "warn",
sourceUrl: `https://www.skills.sh/${LOCAL_SKILLS_SH_EXTERNAL_ID}/security/snyk`,
},
},
inferredCategories: ["security"],
inferredTopics: ["risk-management", "security-audit"],
inferredCategories: ["automation"],
inferredTopics: ["reddit", "marketing"],
sourceFreshnessStatus: "observed-only" as const,
detailStatus: "missing" as const,
observationFingerprint: "local-canonical-search-v1",
sourceSnapshotId: "local-canonical-search-v1",
detailStatus: "available" as const,
observationFingerprint: LOCAL_SKILLS_SH_CONTENT_HASH,
sourceSnapshotId: LOCAL_SKILLS_SH_SNAPSHOT_ID,
lastObservedRunId: runId,
active: true,
publicVisible: true,
@@ -1022,15 +1097,45 @@ export const seedCanonicalSearchFixture = internalMutation({
updatedAt: now,
};
let digestId: Id<"skillsShMirrorDigests">;
if (existing) {
await ctx.db.patch(existing._id, digest);
return { ok: true as const, digestId: existing._id };
digestId = existing._id;
} else {
digestId = await ctx.db.insert("skillsShMirrorDigests", {
...digest,
createdAt: now,
});
}
const digestId = await ctx.db.insert("skillsShMirrorDigests", {
...digest,
const existingDetail = await ctx.db
.query("skillsShMirrorDetails")
.withIndex("by_external_id", (q) => q.eq("externalId", LOCAL_SKILLS_SH_EXTERNAL_ID))
.unique();
const detail = {
externalId: LOCAL_SKILLS_SH_EXTERNAL_ID,
digestId,
contentKind: "skill-md" as const,
path: "SKILL.md",
content: LOCAL_SKILLS_SH_CONTENT,
contentBytes: new TextEncoder().encode(LOCAL_SKILLS_SH_CONTENT).byteLength,
sourceBytes: new TextEncoder().encode(LOCAL_SKILLS_SH_CONTENT).byteLength,
sourceFileCount: 1,
truncated: false,
sourceContentHash: LOCAL_SKILLS_SH_CONTENT_HASH,
sourceSnapshotId: LOCAL_SKILLS_SH_SNAPSHOT_ID,
lastObservedRunId: runId,
updatedAt: now,
};
if (existingDetail) {
await ctx.db.patch(existingDetail._id, detail);
return { ok: true as const, digestId, detailId: existingDetail._id };
}
const detailId = await ctx.db.insert("skillsShMirrorDetails", {
...detail,
createdAt: now,
});
return { ok: true as const, digestId };
return { ok: true as const, digestId, detailId };
},
});
+11
View File
@@ -138,6 +138,7 @@ describe("dev-worktree helpers", () => {
expect(byName.DEV_AUTH_CONVEX_DEPLOYMENT).toBe("anonymous:anonymous-agent");
expect(byName.SECURITY_SCAN_WORKER_TOKEN).toBe("local-dev-worker-token");
expect(byName.SECURITY_SCAN_DEFAULT_VT_WAIT_MS).toBe("0");
expect(byName.CLAWHUB_SKILLS_SH_ROLLOUT_MODE).toBe("test");
expect(byName.AUTH_GITHUB_ID).toBe("local-dev");
expect(byName.AUTH_GITHUB_SECRET).toBe("local-dev");
expect(byName.JWT_PRIVATE_KEY).toContain("BEGIN PRIVATE KEY");
@@ -147,6 +148,16 @@ describe("dev-worktree helpers", () => {
expect(byName.CONVEX_SITE_URL).toBeUndefined();
});
it("preserves an explicit local skills.sh rollout override", () => {
const changes = buildLocalConvexEnvChanges({
CONVEX_DEPLOYMENT: "anonymous:anonymous-agent",
CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "off",
});
const byName = Object.fromEntries(changes.map((change) => [change.name, change.value]));
expect(byName.CLAWHUB_SKILLS_SH_ROLLOUT_MODE).toBe("off");
});
it("does not apply local dev auth overrides for cloud Convex URLs", () => {
const env: NodeJS.ProcessEnv = {
SECURITY_SCAN_WORKER_TOKEN: "remote-worker-token",
+4
View File
@@ -139,6 +139,10 @@ export function buildLocalConvexEnvChanges(env: NodeJS.ProcessEnv) {
{ name: "DEV_AUTH_CONVEX_DEPLOYMENT", value: deployment },
{ name: "SECURITY_SCAN_WORKER_TOKEN", value: LOCAL_DEV_WORKER_TOKEN },
{ name: "SECURITY_SCAN_DEFAULT_VT_WAIT_MS", value: "0" },
{
name: "CLAWHUB_SKILLS_SH_ROLLOUT_MODE",
value: env.CLAWHUB_SKILLS_SH_ROLLOUT_MODE?.trim() || "test",
},
{ name: "JWT_PRIVATE_KEY", value: authKeys.JWT_PRIVATE_KEY },
{ name: "JWKS", value: authKeys.JWKS },
{ name: "AUTH_GITHUB_ID", value: env.AUTH_GITHUB_ID?.trim() || "local-dev" },
+14
View File
@@ -8,6 +8,10 @@ describe("shared seed runner", () => {
command: "bunx",
args: ["convex", "run", "--no-push", "devSeed:seedLocalFixtures"],
},
{
command: "bunx",
args: ["convex", "run", "--no-push", "devSeed:seedCanonicalSearchFixture"],
},
{
command: "bun",
args: ["scripts/public-corpus/seed-public-corpus.ts"],
@@ -29,6 +33,16 @@ describe("shared seed runner", () => {
command: "bunx",
args: ["convex", "run", "--preview-name", "feature/demo", "devSeed:seedLocalFixtures"],
},
{
command: "bunx",
args: [
"convex",
"run",
"--preview-name",
"feature/demo",
"devSeed:seedCanonicalSearchFixture",
],
},
{
command: "bun",
args: ["scripts/public-corpus/seed-public-corpus.ts", "--preview-name", "feature/demo"],
+4
View File
@@ -42,6 +42,10 @@ export function buildSeedSteps(options: SeedOptions): SeedStep[] {
command: "bunx",
args: ["convex", "run", ...convexTargetArgs, "devSeed:seedLocalFixtures"],
},
{
command: "bunx",
args: ["convex", "run", ...convexTargetArgs, "devSeed:seedCanonicalSearchFixture"],
},
{
command: "bun",
args: ["scripts/public-corpus/seed-public-corpus.ts", ...corpusTargetArgs],
@@ -1,6 +1,7 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CanonicalTrendingItem } from "../lib/trendingApi";
const navigateMock = vi.fn();
const convexQueryMock = vi.fn();
@@ -114,17 +115,50 @@ describe("HomeListingSection", () => {
expect(screen.getByText("29")).toBeTruthy();
expect(screen.queryByText("17")).toBeNull();
expect(screen.queryByText("3")).toBeNull();
expect(screen.getByText("24h downloads")).toBeTruthy();
expect(screen.getAllByLabelText("24-hour downloads")).toHaveLength(2);
expect(screen.getByText("Downloads")).toBeTruthy();
expect(screen.getAllByLabelText("Downloads")).toHaveLength(2);
expect(screen.queryByText("24h installs")).toBeNull();
expect(screen.queryByLabelText("24-hour installs")).toBeNull();
expect(screen.queryByText("9K")).toBeNull();
expect(screen.queryByText("8K")).toBeNull();
expect(screen.queryByText("skills.sh")).toBeNull();
expect(document.querySelector(".home-v2-listing-row-icon")).toBeNull();
expect(document.querySelector(".home-v2-listing-row-stats svg")).toBeNull();
expect(screen.queryByRole("button", { name: "Grid view" })).toBeNull();
});
it("identifies skills.sh rows by their source owner and upstream install count", () => {
const external = {
...makeTrending("reddit-automation", "reddit-automation", 0, 12_345, 0),
id: "skills-sh:doany-skills/skills/reddit-automation",
source: "skills-sh" as const,
canonicalUrl: "/skills-sh/doany-skills/skills/reddit-automation",
publisher: null,
sourceIdentity: {
id: "doany-skills/skills/reddit-automation",
owner: "doany-skills",
repo: "skills",
host: null,
lifetimeInstalls: 12_345,
},
metrics: {
trending24hDownloads: null,
trending24hInstalls: null,
trending24hBookmarks: null,
lifetimeInstalls: 12_345,
lifetimeInstallsPeriod: "lifetime" as const,
updatedAt: 1,
},
};
render(<HomeListingSection initialListing={initialTrending([external])} />);
expect(screen.getByText("@doany-skills")).toBeTruthy();
expect(screen.getByText("skills.sh")).toBeTruthy();
expect(screen.getByLabelText("Downloads").textContent).toContain("12.3k");
});
it("hides unavailable Trending and falls back to the Featured feed", async () => {
render(<HomeListingSection initialListing={initialTrending([], false, "unavailable")} />);
@@ -273,7 +307,7 @@ describe("HomeListingSection", () => {
});
function initialTrending(
items: ReturnType<typeof makeTrending>[],
items: CanonicalTrendingItem[],
hasMore = false,
trendingState: "available" | "empty" | "unavailable" = items.length ? "available" : "empty",
) {
@@ -288,7 +322,7 @@ function initialTrending(
};
}
function canonicalPage(items: ReturnType<typeof makeTrending>[], nextCursor: string | null = null) {
function canonicalPage(items: CanonicalTrendingItem[], nextCursor: string | null = null) {
return {
kind: "skills" as const,
snapshotId: "snapshot-1",
@@ -308,7 +342,7 @@ function makeTrending(
installs: number,
lifetime: number,
downloads = installs,
) {
): CanonicalTrendingItem {
return {
id: `clawhub:${slug}`,
source: "clawhub" as const,
+3 -3
View File
@@ -167,9 +167,9 @@ describe("HomeListingSection", () => {
expect(screen.queryByRole("button", { name: "Grid view" })).toBeNull();
expect(screen.getByText("Demo Plugin")).toBeTruthy();
expect(document.querySelector(".home-v2-listing-list")).toBeTruthy();
expect(document.querySelector(".marketplace-icon-image")?.getAttribute("src")).toBe(
featuredPlugin.icon,
);
expect(screen.getByText("Downloads")).toBeTruthy();
expect(document.querySelector(".home-v2-listing-row-icon")).toBeNull();
expect(document.querySelector(".home-v2-listing-row-stats svg")).toBeNull();
});
it("previews long skill and plugin names while retaining their full labels", async () => {
+23 -62
View File
@@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router";
import { Bookmark, CloudOff, Download, Loader2, Moon, Plus } from "lucide-react";
import { CloudOff, Loader2, Moon, Plus } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import {
fetchHomePluginListing as fetchPluginListing,
@@ -20,7 +20,6 @@ import type { PackageListItem } from "../lib/packageApi";
import { buildPluginDetailHref } from "../lib/pluginRoutes";
import { presentationTitle } from "../lib/presentationTitle";
import { PUBLIC_CATALOG_NAME_PREVIEW_LENGTH, truncateText } from "../lib/truncateText";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { OfficialBadge } from "./OfficialBadge";
import { BrowseResultsSkeleton } from "./skeletons/BrowseResultsSkeleton";
@@ -122,33 +121,38 @@ function skillLink(entry: HomeNativeSkillListingEntry) {
return `/${encodeURIComponent(owner)}/${encodeURIComponent(entry.skill.slug)}`;
}
function HomeListingSkillRow({ entry, showStats }: { entry: SkillPageEntry; showStats: boolean }) {
function HomeListingSkillRow({ entry }: { entry: SkillPageEntry }) {
if (isHomeTrendingSkillEntry(entry)) {
const item = entry.trending;
const owner = item.publisher?.handle;
const isSkillsSh = item.source === "skills-sh";
const owner = isSkillsSh
? (item.sourceIdentity?.owner ?? item.sourceIdentity?.host)
: item.publisher?.handle;
const upstreamInstalls = item.sourceIdentity?.lifetimeInstalls ?? item.metrics.lifetimeInstalls;
return (
<Link to={item.canonicalUrl} className="home-v2-listing-row">
<span className="home-v2-listing-row-icon" aria-hidden="true">
<MarketplaceIcon kind="skill" label={item.displayName} size="sm" />
</span>
<div className="home-v2-listing-row-body">
<div className="home-v2-listing-row-title">
<span className="home-v2-listing-row-name" title={item.displayName}>
{truncateText(item.displayName, PUBLIC_CATALOG_NAME_PREVIEW_LENGTH)}
</span>
{isSkillsSh ? <span className="home-v2-listing-source-badge">skills.sh</span> : null}
{owner ? <span className="home-v2-listing-row-by">@{owner}</span> : null}
</div>
<p className="home-v2-listing-row-summary">
{truncateText(item.summary || "Agent-ready skill pack.", 80)}
</p>
</div>
{typeof item.metrics.trending24hDownloads === "number" ? (
<div className="home-v2-listing-row-stats" aria-label="24-hour downloads">
<span>
<Download size={13} aria-hidden="true" />
{formatCompactStat(item.metrics.trending24hDownloads)}
{isSkillsSh && typeof upstreamInstalls === "number" ? (
<div className="home-v2-listing-row-stats" aria-label="Downloads">
<span title={`${upstreamInstalls.toLocaleString()} skills.sh installs`}>
{formatCompactStat(upstreamInstalls)}
</span>
</div>
) : typeof item.metrics.trending24hDownloads === "number" ? (
<div className="home-v2-listing-row-stats" aria-label="Downloads">
<span>{formatCompactStat(item.metrics.trending24hDownloads)}</span>
</div>
) : null}
</Link>
);
@@ -157,19 +161,7 @@ function HomeListingSkillRow({ entry, showStats }: { entry: SkillPageEntry; show
const name = presentationTitle(entry.skill.displayName, entry.skill.slug);
return (
<Link
to={skillLink(entry)}
className={`home-v2-listing-row${showStats ? "" : " has-no-stats"}`}
>
<span className="home-v2-listing-row-icon" aria-hidden="true">
<MarketplaceIcon
kind="skill"
label={name}
imageUrl={entry.skill.icon}
skill={entry.skill}
size="sm"
/>
</span>
<Link to={skillLink(entry)} className="home-v2-listing-row">
<div className="home-v2-listing-row-body">
<div className="home-v2-listing-row-title">
<span className="home-v2-listing-row-name" title={name}>
@@ -181,18 +173,9 @@ function HomeListingSkillRow({ entry, showStats }: { entry: SkillPageEntry; show
{truncateText(entry.skill.summary || "Agent-ready skill pack.", 80)}
</p>
</div>
{showStats ? (
<div className="home-v2-listing-row-stats" aria-label="Popularity">
<span>
<Bookmark size={13} aria-hidden="true" />
{formatCompactStat(entry.skill.stats?.stars ?? 0)}
</span>
<span>
<Download size={13} aria-hidden="true" />
{formatCompactStat(entry.skill.stats?.downloads ?? 0)}
</span>
</div>
) : null}
<div className="home-v2-listing-row-stats" aria-label="Downloads">
<span>{formatCompactStat(entry.skill.stats?.downloads ?? 0)}</span>
</div>
</Link>
);
}
@@ -203,15 +186,6 @@ 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}
imageUrl={plugin.icon}
categorySlug={plugin.categories?.[0]}
size="sm"
/>
</span>
<div className="home-v2-listing-row-body">
<div className="home-v2-listing-row-title">
<span className="home-v2-listing-row-name" title={name}>
@@ -227,10 +201,7 @@ function HomeListingPluginRow({ plugin }: { plugin: PackageListItem }) {
</p>
</div>
<div className="home-v2-listing-row-stats" aria-label="Downloads">
<span>
<Download size={13} aria-hidden="true" />
{formatCompactStat(plugin.stats?.downloads ?? 0)}
</span>
<span>{formatCompactStat(plugin.stats?.downloads ?? 0)}</span>
</div>
</Link>
);
@@ -307,7 +278,6 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
const activeItems = kind === "skills" ? skills : plugins;
const activeStatus = status;
const isEmpty = activeStatus === "idle" && activeItems.length === 0;
const showSkillStats = true;
const showListingMore =
activeStatus === "idle" && (activeItems.length > visibleCount || listingHasMore);
@@ -487,19 +457,11 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
</div>
{activeStatus === "idle" && activeItems.length > 0 ? (
<div
className={`home-v2-listing-head${showSkillStats ? "" : " has-no-stats"}`}
aria-hidden="true"
>
<span className="home-v2-listing-head-icon-spacer" />
<div className="home-v2-listing-head" aria-hidden="true">
<span className="home-v2-listing-head-label">
{kind === "skills" ? "Skill" : "Plugin"}
</span>
{kind === "skills" && tab === "trending" ? (
<span className="home-v2-listing-head-stat">24h downloads</span>
) : showSkillStats ? (
<span className="home-v2-listing-head-stat">Popularity</span>
) : null}
<span className="home-v2-listing-head-stat">Downloads</span>
</div>
) : null}
@@ -532,7 +494,6 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
<HomeListingSkillRow
key={isHomeTrendingSkillEntry(entry) ? entry.trending.id : String(entry.skill._id)}
entry={entry}
showStats={showSkillStats}
/>
))}
</div>
+113 -114
View File
@@ -31,6 +31,7 @@ import { DetailBody, DetailPageShell } from "./DetailPageShell";
import { DetailSecuritySummary } from "./DetailSecuritySummary";
import { GenericNotFoundPage } from "./GenericNotFoundPage";
import { SkillDetailSkeleton } from "./skeletons/SkillDetailSkeleton";
import { SkillDetailPageView } from "./SkillDetailPageView";
import { SkillDetailTabs, type DetailTab } from "./SkillDetailTabs";
import {
buildSkillHref,
@@ -39,7 +40,6 @@ import {
formatOsList,
stripFrontmatter,
} from "./skillDetailUtils";
import { SkillHeader } from "./SkillHeader";
import { buildSkillInstallTabs } from "./SkillInstallCard";
import { SkillOwnershipPanel } from "./SkillOwnershipPanel";
import { SkillPublishSuccessDialog } from "./SkillPublishSuccessDialog";
@@ -882,123 +882,122 @@ export function SkillDetailPage({
}
return (
<main className="section detail-page-section skill-detail-page">
<DetailPageShell>
<SkillHeader
skill={displayedSkill}
owner={owner}
ownerHandle={ownerHandle}
latestVersion={latestVersion}
modInfo={modInfo}
canManage={canManage}
isAuthenticated={isAuthenticated}
isStaff={isStaff}
isStarred={effectiveIsStarred}
onToggleStar={() => void handleToggleStar()}
onOpenReport={openReportDialog}
onRequireSignIn={requireSignIn}
forkOf={forkOf}
forkOfLabel={forkOfLabel}
forkOfHref={forkOfHref}
forkOfOwnerHandle={forkOfOwnerHandle}
canonical={canonical}
canonicalHref={canonicalHref}
canonicalOwnerHandle={canonicalOwnerHandle}
staffVisibilityTag={staffVisibilityTag}
isAutoHidden={isAutoHidden}
isRemoved={isRemoved}
nixPlugin={nixPlugin}
hasPluginBundle={hasPluginBundle}
configRequirements={configRequirements}
cliHelp={cliHelp}
clawdis={clawdis}
category={relatedCategory}
categories={relatedCategories}
staffVisibilityAlert={staffVisibilityAlert}
securityAuditSummary={securitySummary}
activityTrend={activityTrend}
activityTrendLoading={activityTrendLoading}
newVersionHref={newVersionHref}
settingsHref={settingsHref}
showArchiveMetadata={!isGitHubBackedSkill}
>
{nixSnippet ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">Install via Nix</h3>
<pre className="hero-install-code mt-2">{nixSnippet}</pre>
</Card>
) : null}
{configExample ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">Config example</h3>
<pre className="hero-install-code mt-2">{configExample}</pre>
</Card>
) : null}
<SkillDetailTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
onCompareIntent={() => setShouldPrefetchCompare(true)}
readmeContent={readmeContent}
readmeError={displayedReadmeError}
skillCardContent={displayedSkillCard}
skillCardError={displayedSkillCardError}
hasSkillCard={hasSkillCard}
latestFiles={latestFiles}
latestVersionId={latestVersion?._id ?? null}
latestVersion={latestVersion?.version ?? null}
canDeleteVersions={canDeleteSkillVersions}
skill={skill as Doc<"skills">}
ownerHandle={ownerHandle}
diffVersions={diffVersions}
versions={versions}
nixPlugin={Boolean(nixPlugin)}
showArchiveTabs={!isGitHubBackedSkill}
suppressVersionScanResults={suppressVersionScanResults}
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
clawdis={clawdis}
osLabels={osLabels}
readmeHrefResolver={readmeHrefResolver}
<SkillDetailPageView
skill={displayedSkill}
owner={owner}
ownerHandle={ownerHandle}
latestVersion={latestVersion}
modInfo={modInfo}
canManage={canManage}
isAuthenticated={isAuthenticated}
isStaff={isStaff}
isStarred={effectiveIsStarred}
onToggleStar={() => void handleToggleStar()}
onOpenReport={openReportDialog}
onRequireSignIn={requireSignIn}
forkOf={forkOf}
forkOfLabel={forkOfLabel}
forkOfHref={forkOfHref}
forkOfOwnerHandle={forkOfOwnerHandle}
canonical={canonical}
canonicalHref={canonicalHref}
canonicalOwnerHandle={canonicalOwnerHandle}
staffVisibilityTag={staffVisibilityTag}
isAutoHidden={isAutoHidden}
isRemoved={isRemoved}
nixPlugin={nixPlugin}
hasPluginBundle={hasPluginBundle}
configRequirements={configRequirements}
cliHelp={cliHelp}
clawdis={clawdis}
category={relatedCategory}
categories={relatedCategories}
staffVisibilityAlert={staffVisibilityAlert}
securityAuditSummary={securitySummary}
activityTrend={activityTrend}
activityTrendLoading={activityTrendLoading}
newVersionHref={newVersionHref}
settingsHref={settingsHref}
showArchiveMetadata={!isGitHubBackedSkill}
pageOverlays={
<>
<SkillReportDialog
isOpen={isAuthenticated && isReportDialogOpen}
isSubmitting={isSubmittingReport}
reportReason={reportReason}
reportError={reportError}
onReasonChange={setReportReason}
onCancel={closeReportDialog}
onSubmit={() => void submitReport()}
/>
<SkillRelatedSection
category={relatedCategory}
relatedSkills={relatedSkillsResult?.items ?? []}
isLoading={shouldLoadRelatedSkills && relatedSkillsResult === undefined}
variant="compact"
<SkillPublishSuccessDialog
isOpen={showPublishSuccessDialog}
displayName={skill.displayName}
skillPath={detailHref}
skill={skill}
publisher={
owner
? {
displayName: owner.displayName,
handle: owner.handle ?? ownerHandle,
image: owner.image,
kind: owner.kind,
}
: ownerHandle
? { handle: ownerHandle }
: null
}
categoryLabel={relatedCategory?.label ?? null}
onDismiss={onDismissPostPublish ?? (() => undefined)}
/>
</SkillHeader>
</DetailPageShell>
</>
}
>
{nixSnippet ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">Install via Nix</h3>
<pre className="hero-install-code mt-2">{nixSnippet}</pre>
</Card>
) : null}
<SkillReportDialog
isOpen={isAuthenticated && isReportDialogOpen}
isSubmitting={isSubmittingReport}
reportReason={reportReason}
reportError={reportError}
onReasonChange={setReportReason}
onCancel={closeReportDialog}
onSubmit={() => void submitReport()}
{configExample ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">Config example</h3>
<pre className="hero-install-code mt-2">{configExample}</pre>
</Card>
) : null}
<SkillDetailTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
onCompareIntent={() => setShouldPrefetchCompare(true)}
readmeContent={readmeContent}
readmeError={displayedReadmeError}
skillCardContent={displayedSkillCard}
skillCardError={displayedSkillCardError}
hasSkillCard={hasSkillCard}
latestFiles={latestFiles}
latestVersionId={latestVersion?._id ?? null}
latestVersion={latestVersion?.version ?? null}
canDeleteVersions={canDeleteSkillVersions}
skill={skill as Doc<"skills">}
ownerHandle={ownerHandle}
diffVersions={diffVersions}
versions={versions}
nixPlugin={Boolean(nixPlugin)}
showArchiveTabs={!isGitHubBackedSkill}
suppressVersionScanResults={suppressVersionScanResults}
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
clawdis={clawdis}
osLabels={osLabels}
readmeHrefResolver={readmeHrefResolver}
/>
<SkillPublishSuccessDialog
isOpen={showPublishSuccessDialog}
displayName={skill.displayName}
skillPath={detailHref}
skill={skill}
publisher={
owner
? {
displayName: owner.displayName,
handle: owner.handle ?? ownerHandle,
image: owner.image,
kind: owner.kind,
}
: ownerHandle
? { handle: ownerHandle }
: null
}
categoryLabel={relatedCategory?.label ?? null}
onDismiss={onDismissPostPublish ?? (() => undefined)}
<SkillRelatedSection
category={relatedCategory}
relatedSkills={relatedSkillsResult?.items ?? []}
isLoading={shouldLoadRelatedSkills && relatedSkillsResult === undefined}
variant="compact"
/>
</main>
</SkillDetailPageView>
);
}
@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Id } from "../../convex/_generated/dataModel";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { SkillHeader } from "./SkillHeader";
import { SkillDetailPageView } from "./SkillDetailPageView";
import { TooltipProvider } from "./ui/tooltip";
vi.mock("@tanstack/react-router", () => ({
@@ -31,7 +31,7 @@ vi.mock("../lib/useHeroCreatorPublisher", () => ({
useHeroCreatorPublisher: ({ owner }: { owner?: PublicPublisher | null }) => owner,
}));
describe("SkillHeader", () => {
describe("SkillDetailPageView", () => {
function sidebarStatsRoot(container: HTMLElement) {
const node = container.querySelector(".detail-sidebar-stats");
if (!node) throw new Error("Missing .detail-sidebar-stats");
@@ -121,8 +121,8 @@ describe("SkillHeader", () => {
linkedUserId: "users:owner" as Id<"users">,
};
function renderHeader(overrides: Partial<Parameters<typeof SkillHeader>[0]> = {}) {
const props: Parameters<typeof SkillHeader>[0] = {
function renderHeader(overrides: Partial<Parameters<typeof SkillDetailPageView>[0]> = {}) {
const props: Parameters<typeof SkillDetailPageView>[0] = {
skill,
owner,
ownerHandle: "local",
@@ -157,7 +157,7 @@ describe("SkillHeader", () => {
return render(
<TooltipProvider>
<SkillHeader {...props} />
<SkillDetailPageView {...props} />
</TooltipProvider>,
);
}
@@ -387,7 +387,7 @@ describe("SkillHeader", () => {
isAuthenticated: true,
settingsHref: "/local/demo/settings",
newVersionHref: "/skills/publish?updateSlug=demo&ownerHandle=local",
} as Partial<Parameters<typeof SkillHeader>[0]>);
} as Partial<Parameters<typeof SkillDetailPageView>[0]>);
const newVersionLink = screen.getByRole("link", { name: "New version" });
const settingsLink = screen.getByRole("link", { name: "Settings" });
@@ -448,7 +448,7 @@ describe("SkillHeader", () => {
isAuthenticated: true,
settingsHref: "/local/demo/settings",
newVersionHref: "/skills/publish?updateSlug=demo&ownerHandle=local",
} as Partial<Parameters<typeof SkillHeader>[0]>);
} as Partial<Parameters<typeof SkillDetailPageView>[0]>);
expect(screen.queryByRole("button", { name: "Report" })).toBeNull();
});
@@ -460,7 +460,7 @@ describe("SkillHeader", () => {
isStaff: true,
settingsHref: "/local/demo/settings",
newVersionHref: "/skills/publish?updateSlug=demo&ownerHandle=local",
} as Partial<Parameters<typeof SkillHeader>[0]>);
} as Partial<Parameters<typeof SkillDetailPageView>[0]>);
expect(screen.getByRole("button", { name: "Report" })).toBeTruthy();
});
+160
View File
@@ -0,0 +1,160 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SkillDetailPageView, type SkillDetailPageViewProps } from "./SkillDetailPageView";
import { TooltipProvider } from "./ui/tooltip";
vi.mock("@tanstack/react-router", () => ({
Link: ({ children, to }: { children?: ReactNode; to?: string }) => (
<a href={to ?? "#"}>{children}</a>
),
}));
vi.mock("../lib/useHeroCreatorPublisher", () => ({
useHeroCreatorPublisher: () => null,
}));
describe("SkillDetailPageView", () => {
beforeEach(() => {
vi.stubGlobal("matchMedia", () => ({
matches: false,
media: "",
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
});
it("owns the whole shared detail page while rendering source-specific slots", () => {
const props: SkillDetailPageViewProps = {
skill: {
slug: "demo",
displayName: "Demo Skill",
summary: "Shared detail page",
stats: { downloads: 12, stars: 0 },
updatedAt: 1,
},
owner: null,
ownerHandle: "publisher",
latestVersion: null,
modInfo: null,
canManage: false,
isAuthenticated: false,
isStaff: false,
isStarred: false,
onToggleStar: vi.fn(),
onOpenReport: vi.fn(),
onRequireSignIn: vi.fn(),
forkOf: null,
forkOfLabel: "fork of",
forkOfHref: null,
forkOfOwnerHandle: null,
canonical: null,
canonicalHref: null,
canonicalOwnerHandle: null,
staffVisibilityTag: null,
isAutoHidden: false,
isRemoved: false,
nixPlugin: undefined,
hasPluginBundle: false,
configRequirements: undefined,
cliHelp: undefined,
clawdis: undefined,
installContent: <div>Source install</div>,
renderSidebarContent: () => <div>Source provenance</div>,
children: <div>Source tabs</div>,
};
render(
<TooltipProvider>
<SkillDetailPageView {...props} />
</TooltipProvider>,
);
expect(screen.getByRole("main").classList.contains("skill-detail-page")).toBe(true);
expect(screen.getByRole("heading", { name: "Demo Skill" })).toBeTruthy();
expect(screen.getByText("Source install")).toBeTruthy();
expect(screen.getByText("Source tabs")).toBeTruthy();
expect(screen.getAllByText("Source provenance")).toHaveLength(2);
});
it("omits the fallback title icon and renders source metadata before taxonomy", () => {
const { container } = render(
<TooltipProvider>
<SkillDetailPageView
{...makeMinimalProps()}
taxonomyPrefix={<a href="https://skills.sh/example/skills/demo">Synced from skills.sh</a>}
categories={[{ slug: "development", label: "Development", icon: "wrench", keywords: [] }]}
/>
</TooltipProvider>,
);
expect(container.querySelector(".marketplace-icon")).toBeNull();
const taxonomy = container.querySelector(".skill-hero-taxonomy-row");
expect(taxonomy).toBeTruthy();
expect(taxonomy?.textContent).toContain("Synced from skills.sh");
expect(taxonomy?.textContent).toContain("Development");
expect(taxonomy?.querySelector(".skill-hero-taxonomy-separator")).toBeTruthy();
});
it("renders the supplied title icon", () => {
const icon = `/api/v1/skill-icons/${"a".repeat(64)}`;
const { container } = render(
<TooltipProvider>
<SkillDetailPageView
{...makeMinimalProps()}
skill={{
...makeMinimalProps().skill,
icon,
}}
/>
</TooltipProvider>,
);
expect(container.querySelector(".marketplace-icon img")?.getAttribute("src")).toBe(icon);
});
});
function makeMinimalProps(): SkillDetailPageViewProps {
return {
skill: {
slug: "demo",
displayName: "Demo Skill",
summary: "Shared detail page",
icon: null,
stats: { downloads: 12, stars: 0 },
updatedAt: 1,
},
owner: null,
ownerHandle: "publisher",
latestVersion: null,
modInfo: null,
canManage: false,
isAuthenticated: false,
isStaff: false,
isStarred: false,
onToggleStar: vi.fn(),
onOpenReport: vi.fn(),
onRequireSignIn: vi.fn(),
forkOf: null,
forkOfLabel: "fork of",
forkOfHref: null,
forkOfOwnerHandle: null,
canonical: null,
canonicalHref: null,
canonicalOwnerHandle: null,
staffVisibilityTag: null,
isAutoHidden: false,
isRemoved: false,
nixPlugin: undefined,
hasPluginBundle: false,
configRequirements: undefined,
cliHelp: undefined,
clawdis: undefined,
};
}
+838
View File
@@ -0,0 +1,838 @@
import { Link } from "@tanstack/react-router";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
import { Bookmark, Flag, Settings, ShieldCheck, Upload } from "lucide-react";
import { useState, type ReactNode } from "react";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import type { ActivityTrend } from "../lib/activityTrend";
import { getSkillBadges, isSkillOfficial } from "../lib/badges";
import { BrowseCategoryIcon } from "../lib/browseCategoryIcons";
import {
buildSkillCategoryBrowseHref,
buildSkillTopicBrowseHref,
formatCatalogTopicLabel,
type SkillCategory,
} from "../lib/categories";
import { formatSkillStatsTriplet } from "../lib/numberFormat";
import { buildPublisherProfileHref } from "../lib/ownerRoute";
import { presentationTitle } from "../lib/presentationTitle";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { timeAgo } from "../lib/timeAgo";
import { useHeroCreatorPublisher } from "../lib/useHeroCreatorPublisher";
import { useMediaQuery } from "../lib/useMediaQuery";
import { cn } from "../lib/utils";
import { ActivityMetricLabel } from "./ActivityMetricLabel";
import { DetailHero, DetailPageShell, DETAIL_HERO_TOPIC_LIMIT } from "./DetailPageShell";
import { DetailSecuritySummaryLabel } from "./DetailSecuritySummary";
import { useDownloadsSidebarMetricBlock } from "./DownloadsMetricCard";
import { InlineCodeSummary } from "./InlineCodeSummary";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { SidebarMetadata } from "./SidebarMetadata";
import { buildSkillHref } from "./skillDetailUtils";
import { SkillCommandLineCard } from "./SkillInstallSurface";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { UserBadge } from "./UserBadge";
type SkillModerationInfo = {
isPendingScan: boolean;
isMalwareBlocked: boolean;
isSuspicious: boolean;
isHiddenByMod: boolean;
isRemoved: boolean;
overrideActive?: boolean;
verdict?: "clean" | "suspicious" | "malicious";
reason?: string;
};
type SkillFork = {
kind: "fork" | "duplicate";
version: string | null;
skill: { slug: string; displayName: string };
owner: { handle: string | null; userId: Id<"users"> | null };
};
type SkillCanonical = {
skill: { slug: string; displayName: string };
owner: { handle: string | null; userId: Id<"users"> | null };
};
const SUMMARY_COLLAPSE_THRESHOLD = 220;
type MobileDetailPanel = "content" | "stats";
function formatHeaderTopic(topic: string) {
return formatCatalogTopicLabel(topic);
}
type SkillDetailLatestVersion =
| (Omit<Doc<"skillVersions">, "parsed"> & {
parsed?: (Partial<Doc<"skillVersions">["parsed"]> & { description?: string }) | null;
})
| null;
function getLatestVersionDescription(latestVersion: SkillDetailLatestVersion) {
const parsed = latestVersion?.parsed;
const description =
typeof parsed?.presentation?.summary === "string"
? parsed.presentation.summary
: typeof parsed?.description === "string"
? parsed.description
: typeof parsed?.frontmatter?.description === "string"
? parsed.frontmatter.description
: null;
return description?.trim() || null;
}
export type SkillDetailViewSkill = {
_id?: string;
slug: string;
displayName: string;
summary?: string | null;
icon?: string | null;
ownerUserId?: Id<"users">;
ownerPublisherId?: Id<"publishers">;
installKind?: PublicSkill["installKind"];
githubSourceRepo?: string;
githubPath?: string;
githubCurrentCommit?: string;
githubCurrentStatus?: PublicSkill["githubCurrentStatus"];
githubScanStatus?: PublicSkill["githubScanStatus"];
githubHasSkillCard?: boolean;
categories?: string[];
inferredCategories?: string[];
latestVersionId?: Id<"skillVersions">;
inferredFromVersionId?: Id<"skillVersions">;
topics?: string[];
badges?: PublicSkill["badges"];
stats: {
downloads: number;
stars: number;
installs?: number | null;
installsAllTime?: number;
installsCurrent?: number;
versions?: number;
comments?: number;
};
updatedAt: number;
};
function getGitHubRepositoryLink(skill: SkillDetailViewSkill) {
const repo = "githubSourceRepo" in skill ? skill.githubSourceRepo : undefined;
if (skill.installKind !== "github" || !repo) return null;
return (
<a
href={`https://github.com/${repo}`}
target="_blank"
rel="noopener noreferrer"
className="plugin-external-link"
>
{repo}
</a>
);
}
export type SkillDetailPageViewProps = {
skill: SkillDetailViewSkill;
owner: PublicPublisher | null;
ownerHandle: string | null;
latestVersion: SkillDetailLatestVersion;
modInfo: SkillModerationInfo | null;
canManage: boolean;
isAuthenticated: boolean;
isStaff: boolean;
isStarred: boolean | undefined;
onToggleStar: () => void;
onOpenReport: () => void;
onRequireSignIn: () => void;
forkOf: SkillFork | null;
forkOfLabel: string;
forkOfHref: string | null;
forkOfOwnerHandle: string | null;
canonical: SkillCanonical | null;
canonicalHref: string | null;
canonicalOwnerHandle: string | null;
staffVisibilityTag: string | null;
isAutoHidden: boolean;
isRemoved: boolean;
nixPlugin: string | undefined;
hasPluginBundle: boolean;
configRequirements: ClawdisSkillMetadata["config"] | undefined;
cliHelp: string | undefined;
clawdis: ClawdisSkillMetadata | undefined;
category?: SkillCategory | null;
categories?: SkillCategory[] | null;
staffVisibilityAlert?: ReactNode;
postInstallContent?: ReactNode;
securityAuditSummary?: ReactNode;
activityTrend?: ActivityTrend | null;
activityTrendLoading?: boolean;
newVersionHref?: string | null;
settingsHref?: string | null;
showArchiveMetadata?: boolean;
titleAccessory?: ReactNode;
breadcrumbOwnerHref?: string | null;
breadcrumbOwnerLabel?: ReactNode;
breadcrumbSkillHref?: string;
creatorContent?: ReactNode;
heroNotice?: ReactNode;
taxonomyPrefix?: ReactNode;
installContent?: ReactNode;
renderSidebarContent?: () => ReactNode;
showBookmarkAction?: boolean;
showReportAction?: boolean;
pageClassName?: string;
pageOverlays?: ReactNode;
children?: ReactNode;
};
export function SkillDetailPageView({
skill,
owner,
ownerHandle,
latestVersion,
modInfo,
canManage,
isAuthenticated,
isStaff,
isStarred,
onToggleStar,
onOpenReport,
onRequireSignIn,
forkOf,
forkOfLabel,
forkOfHref,
forkOfOwnerHandle,
canonical,
canonicalHref,
canonicalOwnerHandle,
nixPlugin,
hasPluginBundle,
configRequirements,
cliHelp,
clawdis,
category,
categories,
staffVisibilityAlert,
postInstallContent,
securityAuditSummary,
activityTrend,
activityTrendLoading = false,
newVersionHref,
settingsHref,
showArchiveMetadata = true,
titleAccessory,
breadcrumbOwnerHref,
breadcrumbOwnerLabel,
breadcrumbSkillHref,
creatorContent,
heroNotice,
taxonomyPrefix,
installContent,
renderSidebarContent,
showBookmarkAction = true,
showReportAction: showReportActionOverride,
pageClassName,
pageOverlays,
children,
}: SkillDetailPageViewProps) {
const formattedStats = formatSkillStatsTriplet(skill.stats);
const installOwnerId = owner?._id ?? skill.ownerPublisherId ?? skill.ownerUserId ?? null;
const hasOwnerActions = Boolean(newVersionHref) || Boolean(settingsHref);
const showReportAction = showReportActionOverride ?? (!canManage || isStaff);
const badges = getSkillBadges(skill);
const titleBadges = badges.filter((badge) => badge !== "Official");
const heroCreatorPublisher = useHeroCreatorPublisher({
owner,
skillOfficial: isSkillOfficial(skill),
});
const showHeroMeta = Boolean((forkOf && forkOfHref) || canonicalHref);
const showTitleBadges = titleBadges.length > 0;
const headerDescription =
getLatestVersionDescription(latestVersion) ?? skill.summary ?? "No summary provided.";
const headerTopics = (skill.topics ?? [])
.map((topic) => topic.trim())
.filter(Boolean)
.slice(0, DETAIL_HERO_TOPIC_LIMIT);
const headerCategories = (categories ?? (category ? [category] : [])).slice(0, 3);
const hasSummaryToggle = headerDescription.length > SUMMARY_COLLAPSE_THRESHOLD;
const [isSummaryExpanded, setIsSummaryExpanded] = useState(false);
const [mobileDetailPanel, setMobileDetailPanel] = useState<MobileDetailPanel>("content");
const isMobileDetailLayout = useMediaQuery("(max-width: 900px)");
const renderStarAction = () => (
<SignedInActionTooltip
isAuthenticated={isAuthenticated}
message="You must be signed in to bookmark a skill"
>
<button
type="button"
className="skill-sidebar-action-link skill-sidebar-star-action"
onClick={isAuthenticated ? onToggleStar : onRequireSignIn}
aria-pressed={Boolean(isAuthenticated && isStarred)}
aria-label={isStarred ? "Unbookmark skill" : "Bookmark skill"}
>
<Bookmark
size={14}
aria-hidden="true"
fill={isAuthenticated && isStarred ? "currentColor" : "none"}
/>
{isAuthenticated && isStarred ? "Unbookmark" : "Bookmark"}
<span className="skill-action-count">{formattedStats.stars}</span>
</button>
</SignedInActionTooltip>
);
const renderSidebarActions = () => {
if (!showReportAction) return null;
return (
<div className="skill-sidebar-actions skill-sidebar-actions-secondary">
<SignedInActionTooltip
isAuthenticated={isAuthenticated}
message="You must be signed in to report a skill"
>
<button
type="button"
className="skill-sidebar-action-link"
onClick={isAuthenticated ? onOpenReport : onRequireSignIn}
>
<Flag size={14} aria-hidden="true" />
Report
</button>
</SignedInActionTooltip>
</div>
);
};
const managementToolbar =
hasOwnerActions || isStaff || staffVisibilityAlert ? (
<div className="skill-management-toolbar">
{staffVisibilityAlert ? (
<div className="skill-management-toolbar-alert">{staffVisibilityAlert}</div>
) : null}
{hasOwnerActions || isStaff ? (
<div className="skill-management-toolbar-inner">
{newVersionHref ? (
<Button asChild variant="ghost" size="xs" className="skill-management-toolbar-action">
<a href={newVersionHref} aria-label="New version">
<Upload size={13} aria-hidden="true" />
New version
</a>
</Button>
) : null}
{settingsHref ? (
<Button asChild variant="ghost" size="xs" className="skill-management-toolbar-action">
<a href={settingsHref} aria-label="Settings">
<Settings size={13} aria-hidden="true" />
Settings
</a>
</Button>
) : null}
{isStaff ? (
<Button asChild variant="ghost" size="xs" className="skill-management-toolbar-action">
<Link to="/management" search={{ skill: skill.slug, plugin: undefined }}>
<ShieldCheck size={13} aria-hidden="true" />
Manage
</Link>
</Button>
) : null}
</div>
) : null}
</div>
) : null;
const defaultStatsContent = () => (
<>
<SkillSidebarDeferredStats
skill={skill}
owner={owner}
ownerHandle={ownerHandle}
formattedStats={formattedStats}
latestVersion={latestVersion}
showArchiveMetadata={showArchiveMetadata}
securityAuditSummary={securityAuditSummary}
activityTrend={activityTrend}
activityTrendLoading={activityTrendLoading}
hideCreator
/>
{renderSidebarActions()}
</>
);
// Match the normal shell's CSS-exclusive desktop/mobile copies so responsive
// transitions never move or remount the active sidebar subtree.
const desktopStatsContent = renderSidebarContent ? renderSidebarContent() : defaultStatsContent();
const mobileStatsContent = renderSidebarContent ? renderSidebarContent() : defaultStatsContent();
const resolvedBreadcrumbOwnerHref =
breadcrumbOwnerHref === undefined
? ownerHandle
? buildPublisherProfileHref(ownerHandle)
: "#"
: breadcrumbOwnerHref;
const displayName = presentationTitle(skill.displayName, skill.slug);
return (
<main className={cn("section detail-page-section skill-detail-page", pageClassName)}>
<DetailPageShell>
{modInfo?.isPendingScan ? (
<div className="pending-banner">
<div className="pending-banner-content">
<strong>Security scan in progress</strong>
<p>
Your skill is being scanned by VirusTotal. It will be visible to others once the
scan completes. This usually takes up to 5 minutes grab a coffee or exfoliate your
shell while you wait.
</p>
</div>
</div>
) : modInfo?.isRemoved ? (
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<strong>Skill removed by moderator</strong>
<p>This skill has been removed and is not visible to others.</p>
</div>
</div>
) : modInfo?.isHiddenByMod ? (
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<strong>Skill hidden</strong>
<p>This skill is currently hidden and not visible to others.</p>
</div>
</div>
) : null}
{managementToolbar}
<DetailHero
topClassName={hasPluginBundle ? "has-plugin" : undefined}
sidebar={
<div className="skill-hero-sidebar-stack">
{!isMobileDetailLayout && showBookmarkAction ? (
<div className="skill-sidebar-star-band detail-hero-summary-row">
{renderStarAction()}
</div>
) : null}
<div className="detail-sidebar-stats">{desktopStatsContent}</div>
</div>
}
main={
<>
<div className="skill-hero-title">
<nav className="skill-hero-breadcrumbs" aria-label="Skill breadcrumbs">
<a href="/skills">skills</a>
<span aria-hidden="true">/</span>
{resolvedBreadcrumbOwnerHref ? (
<a href={resolvedBreadcrumbOwnerHref}>
{breadcrumbOwnerLabel ??
ownerHandle ??
owner?.displayName ??
owner?._id ??
"unknown"}
</a>
) : (
<span>
{breadcrumbOwnerLabel ??
ownerHandle ??
owner?.displayName ??
owner?._id ??
"unknown"}
</span>
)}
<span aria-hidden="true">/</span>
<a
href={
breadcrumbSkillHref ??
buildSkillHref(ownerHandle, owner?._id ?? null, skill.slug)
}
aria-current="page"
>
{skill.slug}
</a>
</nav>
<div className="skill-hero-heading-stack">
{taxonomyPrefix || headerCategories.length > 0 || headerTopics.length > 0 ? (
<div className="skill-hero-taxonomy-row" aria-label="Skill metadata">
{taxonomyPrefix ? (
<div className="skill-hero-taxonomy-prefix">{taxonomyPrefix}</div>
) : null}
{taxonomyPrefix &&
(headerCategories.length > 0 || headerTopics.length > 0) ? (
<span className="skill-hero-taxonomy-separator" aria-hidden="true" />
) : null}
{headerCategories.length > 0 ? (
<div className="skill-category-meta-list" aria-label="Categories">
{headerCategories.map((categoryItem) => (
<a
key={categoryItem.slug}
className="skill-category-meta-link"
href={buildSkillCategoryBrowseHref(categoryItem)}
aria-label={`View ${categoryItem.label} skills`}
>
<BrowseCategoryIcon
slug={categoryItem.slug}
icon={categoryItem.icon}
size={14}
className="skill-category-icon"
/>
<span>{categoryItem.label}</span>
</a>
))}
</div>
) : null}
{headerCategories.length > 0 && headerTopics.length > 0 ? (
<span className="skill-hero-taxonomy-separator" aria-hidden="true" />
) : null}
{headerTopics.length > 0 ? (
<div className="skill-hero-topic-list" aria-label="Topics">
{headerTopics.map((topic) => (
<a
key={topic}
className="skill-hero-topic"
href={buildSkillTopicBrowseHref(topic)}
aria-label={`View skills tagged ${formatHeaderTopic(topic)}`}
>
{formatHeaderTopic(topic)}
</a>
))}
</div>
) : null}
</div>
) : null}
<div className="skill-hero-title-row">
{skill.icon ? (
<MarketplaceIcon
kind="skill"
label={displayName}
imageUrl={skill.icon}
skill={skill}
size="md"
/>
) : null}
<h1 className="skill-page-title">{displayName}</h1>
{showTitleBadges ? (
<div className="skill-title-badges">
{titleBadges.map((badge) => (
<Badge key={badge} variant="compact">
{badge}
</Badge>
))}
</div>
) : null}
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
{titleAccessory}
</div>
{showHeroMeta ? (
<div className="skill-hero-meta-row" aria-label="Skill lineage">
{forkOf && forkOfHref ? (
<span className="skill-hero-meta-item">
<span className="skill-hero-meta-label">{forkOfLabel}</span>
<a className="skill-hero-meta-link" href={forkOfHref}>
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
{forkOf.skill.slug}
</a>
{forkOf.version ? (
<span className="skill-hero-meta-version">({forkOf.version})</span>
) : null}
</span>
) : null}
{canonicalHref ? (
<span className="skill-hero-meta-item">
<span className="skill-hero-meta-label">canonical</span>
<a className="skill-hero-meta-link" href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
) : null}
</div>
) : null}
</div>
<div className="skill-summary-block">
<p
className={`section-subtitle skill-summary-line${
hasSummaryToggle && !isSummaryExpanded ? " line-clamp-2" : ""
}`}
>
<InlineCodeSummary>{headerDescription}</InlineCodeSummary>
</p>
{hasSummaryToggle ? (
<button
type="button"
className="skill-summary-toggle"
aria-expanded={isSummaryExpanded}
onClick={() => setIsSummaryExpanded((expanded) => !expanded)}
>
{isSummaryExpanded ? "Show less" : "Read more"}
</button>
) : null}
</div>
{creatorContent || owner || ownerHandle ? (
<div className="skill-hero-creator">
{creatorContent ?? (
<UserBadge
user={heroCreatorPublisher}
fallbackHandle={ownerHandle}
prefix=""
size="md"
showName
showHandle={false}
showMutedHandle
stackMutedHandleBelowName
disableTooltip
/>
)}
{isMobileDetailLayout && showBookmarkAction ? (
<div className="skill-hero-creator-star">{renderStarAction()}</div>
) : null}
</div>
) : null}
{heroNotice}
{nixPlugin ? (
<div className="skill-hero-note">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
</div>
) : null}
</div>
</>
}
>
<div className="detail-mobile-install">
{installContent ?? (
<SkillCommandLineCard
slug={skill.slug}
displayName={skill.displayName}
ownerHandle={ownerHandle}
ownerId={installOwnerId}
clawdis={clawdis}
/>
)}
</div>
<div className="detail-mobile-master-tabs" data-active={mobileDetailPanel}>
<div
className="detail-mobile-master-tab-list"
role="tablist"
aria-label="Skill mobile sections"
>
<button
id="skill-mobile-master-tab-content"
className={`detail-mobile-master-tab${
mobileDetailPanel === "content" ? " is-active" : ""
}`}
type="button"
role="tab"
aria-selected={mobileDetailPanel === "content"}
aria-controls="skill-mobile-master-panel-content"
onClick={() => setMobileDetailPanel("content")}
>
SKILL.md
</button>
<button
id="skill-mobile-master-tab-stats"
className={`detail-mobile-master-tab${
mobileDetailPanel === "stats" ? " is-active" : ""
}`}
type="button"
role="tab"
aria-selected={mobileDetailPanel === "stats"}
aria-controls="skill-mobile-master-panel-stats"
onClick={() => setMobileDetailPanel("stats")}
>
Stats & details
</button>
</div>
<div
className="detail-mobile-master-panel detail-mobile-master-panel-content"
id="skill-mobile-master-panel-content"
role="tabpanel"
aria-labelledby="skill-mobile-master-tab-content"
hidden={mobileDetailPanel !== "content"}
>
{postInstallContent}
{children}
{hasPluginBundle ? (
<div className="skill-panel bundle-card">
<div className="bundle-header">
<div className="bundle-title">Plugin bundle (nix)</div>
<div className="bundle-subtitle">Skill pack · CLI binary · Config</div>
</div>
<div className="bundle-includes">
<span>SKILL.md</span>
<span>CLI</span>
<span>Config</span>
</div>
{configRequirements ? (
<div className="bundle-section">
<div className="bundle-section-title">Config requirements</div>
<div className="bundle-meta">
{configRequirements.requiredEnv?.length ? (
<div className="stat">
<strong>Required env</strong>
<span>{configRequirements.requiredEnv.join(", ")}</span>
</div>
) : null}
{configRequirements.stateDirs?.length ? (
<div className="stat">
<strong>State dirs</strong>
<span>{configRequirements.stateDirs.join(", ")}</span>
</div>
) : null}
</div>
</div>
) : null}
{cliHelp ? (
<details className="bundle-section bundle-details">
<summary>CLI help (from plugin)</summary>
<pre className="hero-install-code mono">{cliHelp}</pre>
</details>
) : null}
</div>
) : null}
</div>
<div
className="detail-mobile-master-panel detail-mobile-master-stats"
id="skill-mobile-master-panel-stats"
role="tabpanel"
aria-labelledby="skill-mobile-master-tab-stats"
hidden={mobileDetailPanel !== "stats"}
>
{mobileStatsContent}
</div>
</div>
</DetailHero>
</DetailPageShell>
{pageOverlays}
</main>
);
}
function SignedInActionTooltip({
children,
isAuthenticated,
message,
}: {
children: ReactNode;
isAuthenticated: boolean;
message: string;
}) {
if (isAuthenticated) return children;
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side="top" align="center">
{message}
</TooltipContent>
</Tooltip>
);
}
function SkillSidebarDeferredStats({
skill,
owner,
ownerHandle,
formattedStats,
latestVersion,
showArchiveMetadata,
securityAuditSummary,
activityTrend,
activityTrendLoading = false,
hideCreator = false,
}: {
skill: SkillDetailViewSkill;
owner: PublicPublisher | null;
ownerHandle: string | null;
formattedStats: ReturnType<typeof formatSkillStatsTriplet>;
latestVersion: SkillDetailLatestVersion;
showArchiveMetadata: boolean;
securityAuditSummary?: ReactNode;
activityTrend?: ActivityTrend | null;
activityTrendLoading?: boolean;
hideCreator?: boolean;
}) {
const githubRepositoryLink = getGitHubRepositoryLink(skill);
const downloadsMetricBlock = useDownloadsSidebarMetricBlock({
allTimeDownloads: skill.stats.downloads,
activityTrend: activityTrend?.downloads,
loading: activityTrendLoading,
});
return (
<SidebarMetadata
ariaLabel="Skill metadata"
density="compact"
className="skill-sidebar-deferred-metadata"
blocks={[
activityTrend || activityTrendLoading
? downloadsMetricBlock
: {
label: <ActivityMetricLabel label="Downloads" />,
value: formattedStats.downloads,
large: true,
},
{ label: "Repository", value: githubRepositoryLink },
...(hideCreator
? []
: [
{
label: "Creator",
value: (
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix=""
size="md"
showName
showHandle={false}
showMutedHandle
disableTooltip
/>
),
},
]),
securityAuditSummary
? {
key: "security-audit",
label: <DetailSecuritySummaryLabel />,
value: securityAuditSummary,
}
: { label: "", value: null },
...(showArchiveMetadata
? [
{
grid: [
{
label: "Last updated",
value: (
<span title={new Date(skill.updatedAt).toLocaleString()}>
{timeAgo(skill.updatedAt)}
</span>
),
},
{
label: "Current version",
value: latestVersion?.version ? `v${latestVersion.version}` : "None",
},
],
},
{ label: "License", value: PLATFORM_SKILL_LICENSE },
]
: [
{
label: "Last updated",
value: (
<span title={new Date(skill.updatedAt).toLocaleString()}>
{timeAgo(skill.updatedAt)}
</span>
),
},
]),
]}
/>
);
}
-750
View File
@@ -1,750 +0,0 @@
import { Link } from "@tanstack/react-router";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
import { Bookmark, Flag, Settings, ShieldCheck, Upload } from "lucide-react";
import { useState, type ReactNode } from "react";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import type { ActivityTrend } from "../lib/activityTrend";
import { getSkillBadges, isSkillOfficial } from "../lib/badges";
import { BrowseCategoryIcon } from "../lib/browseCategoryIcons";
import {
buildSkillCategoryBrowseHref,
buildSkillTopicBrowseHref,
formatCatalogTopicLabel,
type SkillCategory,
} from "../lib/categories";
import { formatSkillStatsTriplet } from "../lib/numberFormat";
import { buildPublisherProfileHref } from "../lib/ownerRoute";
import { presentationTitle } from "../lib/presentationTitle";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { timeAgo } from "../lib/timeAgo";
import { useHeroCreatorPublisher } from "../lib/useHeroCreatorPublisher";
import { useMediaQuery } from "../lib/useMediaQuery";
import { ActivityMetricLabel } from "./ActivityMetricLabel";
import { DetailHero, DETAIL_HERO_TOPIC_LIMIT } from "./DetailPageShell";
import { DetailSecuritySummaryLabel } from "./DetailSecuritySummary";
import { useDownloadsSidebarMetricBlock } from "./DownloadsMetricCard";
import { InlineCodeSummary } from "./InlineCodeSummary";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { SidebarMetadata } from "./SidebarMetadata";
import { buildSkillHref } from "./skillDetailUtils";
import { SkillCommandLineCard } from "./SkillInstallSurface";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { UserBadge } from "./UserBadge";
type SkillModerationInfo = {
isPendingScan: boolean;
isMalwareBlocked: boolean;
isSuspicious: boolean;
isHiddenByMod: boolean;
isRemoved: boolean;
overrideActive?: boolean;
verdict?: "clean" | "suspicious" | "malicious";
reason?: string;
};
type SkillFork = {
kind: "fork" | "duplicate";
version: string | null;
skill: { slug: string; displayName: string };
owner: { handle: string | null; userId: Id<"users"> | null };
};
type SkillCanonical = {
skill: { slug: string; displayName: string };
owner: { handle: string | null; userId: Id<"users"> | null };
};
const SUMMARY_COLLAPSE_THRESHOLD = 220;
type MobileDetailPanel = "content" | "stats";
function formatHeaderTopic(topic: string) {
return formatCatalogTopicLabel(topic);
}
type SkillHeaderLatestVersion =
| (Omit<Doc<"skillVersions">, "parsed"> & {
parsed?: (Partial<Doc<"skillVersions">["parsed"]> & { description?: string }) | null;
})
| null;
function getLatestVersionDescription(latestVersion: SkillHeaderLatestVersion) {
const parsed = latestVersion?.parsed;
const description =
typeof parsed?.presentation?.summary === "string"
? parsed.presentation.summary
: typeof parsed?.description === "string"
? parsed.description
: typeof parsed?.frontmatter?.description === "string"
? parsed.frontmatter.description
: null;
return description?.trim() || null;
}
function getGitHubRepositoryLink(skill: Doc<"skills"> | PublicSkill) {
const repo = "githubSourceRepo" in skill ? skill.githubSourceRepo : undefined;
if (skill.installKind !== "github" || !repo) return null;
return (
<a
href={`https://github.com/${repo}`}
target="_blank"
rel="noopener noreferrer"
className="plugin-external-link"
>
{repo}
</a>
);
}
type SkillHeaderProps = {
skill: Doc<"skills"> | PublicSkill;
owner: PublicPublisher | null;
ownerHandle: string | null;
latestVersion: SkillHeaderLatestVersion;
modInfo: SkillModerationInfo | null;
canManage: boolean;
isAuthenticated: boolean;
isStaff: boolean;
isStarred: boolean | undefined;
onToggleStar: () => void;
onOpenReport: () => void;
onRequireSignIn: () => void;
forkOf: SkillFork | null;
forkOfLabel: string;
forkOfHref: string | null;
forkOfOwnerHandle: string | null;
canonical: SkillCanonical | null;
canonicalHref: string | null;
canonicalOwnerHandle: string | null;
staffVisibilityTag: string | null;
isAutoHidden: boolean;
isRemoved: boolean;
nixPlugin: string | undefined;
hasPluginBundle: boolean;
configRequirements: ClawdisSkillMetadata["config"] | undefined;
cliHelp: string | undefined;
clawdis: ClawdisSkillMetadata | undefined;
category?: SkillCategory | null;
categories?: SkillCategory[] | null;
staffVisibilityAlert?: ReactNode;
postInstallContent?: ReactNode;
securityAuditSummary?: ReactNode;
activityTrend?: ActivityTrend | null;
activityTrendLoading?: boolean;
newVersionHref?: string | null;
settingsHref?: string | null;
showArchiveMetadata?: boolean;
children?: ReactNode;
};
export function SkillHeader({
skill,
owner,
ownerHandle,
latestVersion,
modInfo,
canManage,
isAuthenticated,
isStaff,
isStarred,
onToggleStar,
onOpenReport,
onRequireSignIn,
forkOf,
forkOfLabel,
forkOfHref,
forkOfOwnerHandle,
canonical,
canonicalHref,
canonicalOwnerHandle,
nixPlugin,
hasPluginBundle,
configRequirements,
cliHelp,
clawdis,
category,
categories,
staffVisibilityAlert,
postInstallContent,
securityAuditSummary,
activityTrend,
activityTrendLoading = false,
newVersionHref,
settingsHref,
showArchiveMetadata = true,
children,
}: SkillHeaderProps) {
const formattedStats = formatSkillStatsTriplet(skill.stats);
const installOwnerId = owner?._id ?? skill.ownerPublisherId ?? skill.ownerUserId ?? null;
const hasOwnerActions = Boolean(newVersionHref) || Boolean(settingsHref);
const showReportAction = !canManage || isStaff;
const badges = getSkillBadges(skill);
const titleBadges = badges.filter((badge) => badge !== "Official");
const heroCreatorPublisher = useHeroCreatorPublisher({
owner,
skillOfficial: isSkillOfficial(skill),
});
const showHeroMeta = Boolean((forkOf && forkOfHref) || canonicalHref);
const showTitleBadges = titleBadges.length > 0;
const headerDescription =
getLatestVersionDescription(latestVersion) ?? skill.summary ?? "No summary provided.";
const headerTopics = (skill.topics ?? [])
.map((topic) => topic.trim())
.filter(Boolean)
.slice(0, DETAIL_HERO_TOPIC_LIMIT);
const headerCategories = (categories ?? (category ? [category] : [])).slice(0, 3);
const hasSummaryToggle = headerDescription.length > SUMMARY_COLLAPSE_THRESHOLD;
const [isSummaryExpanded, setIsSummaryExpanded] = useState(false);
const [mobileDetailPanel, setMobileDetailPanel] = useState<MobileDetailPanel>("content");
const isMobileDetailLayout = useMediaQuery("(max-width: 900px)");
const renderStarAction = () => (
<SignedInActionTooltip
isAuthenticated={isAuthenticated}
message="You must be signed in to bookmark a skill"
>
<button
type="button"
className="skill-sidebar-action-link skill-sidebar-star-action"
onClick={isAuthenticated ? onToggleStar : onRequireSignIn}
aria-pressed={Boolean(isAuthenticated && isStarred)}
aria-label={isStarred ? "Unbookmark skill" : "Bookmark skill"}
>
<Bookmark
size={14}
aria-hidden="true"
fill={isAuthenticated && isStarred ? "currentColor" : "none"}
/>
{isAuthenticated && isStarred ? "Unbookmark" : "Bookmark"}
<span className="skill-action-count">{formattedStats.stars}</span>
</button>
</SignedInActionTooltip>
);
const renderSidebarActions = () => {
if (!showReportAction) return null;
return (
<div className="skill-sidebar-actions skill-sidebar-actions-secondary">
<SignedInActionTooltip
isAuthenticated={isAuthenticated}
message="You must be signed in to report a skill"
>
<button
type="button"
className="skill-sidebar-action-link"
onClick={isAuthenticated ? onOpenReport : onRequireSignIn}
>
<Flag size={14} aria-hidden="true" />
Report
</button>
</SignedInActionTooltip>
</div>
);
};
const managementToolbar =
hasOwnerActions || isStaff || staffVisibilityAlert ? (
<div className="skill-management-toolbar">
{staffVisibilityAlert ? (
<div className="skill-management-toolbar-alert">{staffVisibilityAlert}</div>
) : null}
{hasOwnerActions || isStaff ? (
<div className="skill-management-toolbar-inner">
{newVersionHref ? (
<Button asChild variant="ghost" size="xs" className="skill-management-toolbar-action">
<a href={newVersionHref} aria-label="New version">
<Upload size={13} aria-hidden="true" />
New version
</a>
</Button>
) : null}
{settingsHref ? (
<Button asChild variant="ghost" size="xs" className="skill-management-toolbar-action">
<a href={settingsHref} aria-label="Settings">
<Settings size={13} aria-hidden="true" />
Settings
</a>
</Button>
) : null}
{isStaff ? (
<Button asChild variant="ghost" size="xs" className="skill-management-toolbar-action">
<Link to="/management" search={{ skill: skill.slug, plugin: undefined }}>
<ShieldCheck size={13} aria-hidden="true" />
Manage
</Link>
</Button>
) : null}
</div>
) : null}
</div>
) : null;
const desktopStatsContent = (
<>
<SkillSidebarDeferredStats
skill={skill}
owner={owner}
ownerHandle={ownerHandle}
formattedStats={formattedStats}
latestVersion={latestVersion}
showArchiveMetadata={showArchiveMetadata}
securityAuditSummary={securityAuditSummary}
activityTrend={activityTrend}
activityTrendLoading={activityTrendLoading}
hideCreator
/>
{renderSidebarActions()}
</>
);
const mobileStatsContent = (
<>
<SkillSidebarDeferredStats
skill={skill}
owner={owner}
ownerHandle={ownerHandle}
formattedStats={formattedStats}
latestVersion={latestVersion}
showArchiveMetadata={showArchiveMetadata}
securityAuditSummary={securityAuditSummary}
activityTrend={activityTrend}
activityTrendLoading={activityTrendLoading}
hideCreator
/>
{renderSidebarActions()}
</>
);
const displayName = presentationTitle(skill.displayName, skill.slug);
return (
<>
{modInfo?.isPendingScan ? (
<div className="pending-banner">
<div className="pending-banner-content">
<strong>Security scan in progress</strong>
<p>
Your skill is being scanned by VirusTotal. It will be visible to others once the scan
completes. This usually takes up to 5 minutes grab a coffee or exfoliate your shell
while you wait.
</p>
</div>
</div>
) : modInfo?.isRemoved ? (
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<strong>Skill removed by moderator</strong>
<p>This skill has been removed and is not visible to others.</p>
</div>
</div>
) : modInfo?.isHiddenByMod ? (
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<strong>Skill hidden</strong>
<p>This skill is currently hidden and not visible to others.</p>
</div>
</div>
) : null}
{managementToolbar}
<DetailHero
topClassName={hasPluginBundle ? "has-plugin" : undefined}
sidebar={
<div className="skill-hero-sidebar-stack">
{!isMobileDetailLayout ? (
<div className="skill-sidebar-star-band detail-hero-summary-row">
{renderStarAction()}
</div>
) : null}
<div className="detail-sidebar-stats">{desktopStatsContent}</div>
</div>
}
main={
<>
<div className="skill-hero-title">
<nav className="skill-hero-breadcrumbs" aria-label="Skill breadcrumbs">
<a href="/skills">skills</a>
<span aria-hidden="true">/</span>
<a href={ownerHandle ? buildPublisherProfileHref(ownerHandle) : "#"}>
{ownerHandle ?? owner?.displayName ?? owner?._id ?? "unknown"}
</a>
<span aria-hidden="true">/</span>
<a
href={buildSkillHref(ownerHandle, owner?._id ?? null, skill.slug)}
aria-current="page"
>
{skill.slug}
</a>
</nav>
<div className="skill-hero-heading-stack">
{headerCategories.length > 0 || headerTopics.length > 0 ? (
<div className="skill-hero-taxonomy-row" aria-label="Skill metadata">
{headerCategories.length > 0 ? (
<div className="skill-category-meta-list" aria-label="Categories">
{headerCategories.map((categoryItem) => (
<a
key={categoryItem.slug}
className="skill-category-meta-link"
href={buildSkillCategoryBrowseHref(categoryItem)}
aria-label={`View ${categoryItem.label} skills`}
>
<BrowseCategoryIcon
slug={categoryItem.slug}
icon={categoryItem.icon}
size={14}
className="skill-category-icon"
/>
<span>{categoryItem.label}</span>
</a>
))}
</div>
) : null}
{headerCategories.length > 0 && headerTopics.length > 0 ? (
<span className="skill-hero-taxonomy-separator" aria-hidden="true" />
) : null}
{headerTopics.length > 0 ? (
<div className="skill-hero-topic-list" aria-label="Topics">
{headerTopics.map((topic) => (
<a
key={topic}
className="skill-hero-topic"
href={buildSkillTopicBrowseHref(topic)}
aria-label={`View skills tagged ${formatHeaderTopic(topic)}`}
>
{formatHeaderTopic(topic)}
</a>
))}
</div>
) : null}
</div>
) : null}
<div className="skill-hero-title-row">
<MarketplaceIcon
kind="skill"
label={displayName}
imageUrl={skill.icon}
skill={skill}
size="md"
/>
<h1 className="skill-page-title">{displayName}</h1>
{showTitleBadges ? (
<div className="skill-title-badges">
{titleBadges.map((badge) => (
<Badge key={badge} variant="compact">
{badge}
</Badge>
))}
</div>
) : null}
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
</div>
{showHeroMeta ? (
<div className="skill-hero-meta-row" aria-label="Skill lineage">
{forkOf && forkOfHref ? (
<span className="skill-hero-meta-item">
<span className="skill-hero-meta-label">{forkOfLabel}</span>
<a className="skill-hero-meta-link" href={forkOfHref}>
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
{forkOf.skill.slug}
</a>
{forkOf.version ? (
<span className="skill-hero-meta-version">({forkOf.version})</span>
) : null}
</span>
) : null}
{canonicalHref ? (
<span className="skill-hero-meta-item">
<span className="skill-hero-meta-label">canonical</span>
<a className="skill-hero-meta-link" href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
) : null}
</div>
) : null}
</div>
<div className="skill-summary-block">
<p
className={`section-subtitle skill-summary-line${
hasSummaryToggle && !isSummaryExpanded ? " line-clamp-2" : ""
}`}
>
<InlineCodeSummary>{headerDescription}</InlineCodeSummary>
</p>
{hasSummaryToggle ? (
<button
type="button"
className="skill-summary-toggle"
aria-expanded={isSummaryExpanded}
onClick={() => setIsSummaryExpanded((expanded) => !expanded)}
>
{isSummaryExpanded ? "Show less" : "Read more"}
</button>
) : null}
</div>
{owner || ownerHandle ? (
<div className="skill-hero-creator">
<UserBadge
user={heroCreatorPublisher}
fallbackHandle={ownerHandle}
prefix=""
size="md"
showName
showHandle={false}
showMutedHandle
stackMutedHandleBelowName
disableTooltip
/>
{isMobileDetailLayout ? (
<div className="skill-hero-creator-star">{renderStarAction()}</div>
) : null}
</div>
) : null}
{nixPlugin ? (
<div className="skill-hero-note">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
</div>
) : null}
</div>
</>
}
>
<div className="detail-mobile-install">
<SkillCommandLineCard
slug={skill.slug}
displayName={skill.displayName}
ownerHandle={ownerHandle}
ownerId={installOwnerId}
clawdis={clawdis}
/>
</div>
<div className="detail-mobile-master-tabs" data-active={mobileDetailPanel}>
<div
className="detail-mobile-master-tab-list"
role="tablist"
aria-label="Skill mobile sections"
>
<button
id="skill-mobile-master-tab-content"
className={`detail-mobile-master-tab${
mobileDetailPanel === "content" ? " is-active" : ""
}`}
type="button"
role="tab"
aria-selected={mobileDetailPanel === "content"}
aria-controls="skill-mobile-master-panel-content"
onClick={() => setMobileDetailPanel("content")}
>
SKILL.md
</button>
<button
id="skill-mobile-master-tab-stats"
className={`detail-mobile-master-tab${
mobileDetailPanel === "stats" ? " is-active" : ""
}`}
type="button"
role="tab"
aria-selected={mobileDetailPanel === "stats"}
aria-controls="skill-mobile-master-panel-stats"
onClick={() => setMobileDetailPanel("stats")}
>
Stats & details
</button>
</div>
<div
className="detail-mobile-master-panel detail-mobile-master-panel-content"
id="skill-mobile-master-panel-content"
role="tabpanel"
aria-labelledby="skill-mobile-master-tab-content"
hidden={mobileDetailPanel !== "content"}
>
{postInstallContent}
{children}
{hasPluginBundle ? (
<div className="skill-panel bundle-card">
<div className="bundle-header">
<div className="bundle-title">Plugin bundle (nix)</div>
<div className="bundle-subtitle">Skill pack · CLI binary · Config</div>
</div>
<div className="bundle-includes">
<span>SKILL.md</span>
<span>CLI</span>
<span>Config</span>
</div>
{configRequirements ? (
<div className="bundle-section">
<div className="bundle-section-title">Config requirements</div>
<div className="bundle-meta">
{configRequirements.requiredEnv?.length ? (
<div className="stat">
<strong>Required env</strong>
<span>{configRequirements.requiredEnv.join(", ")}</span>
</div>
) : null}
{configRequirements.stateDirs?.length ? (
<div className="stat">
<strong>State dirs</strong>
<span>{configRequirements.stateDirs.join(", ")}</span>
</div>
) : null}
</div>
</div>
) : null}
{cliHelp ? (
<details className="bundle-section bundle-details">
<summary>CLI help (from plugin)</summary>
<pre className="hero-install-code mono">{cliHelp}</pre>
</details>
) : null}
</div>
) : null}
</div>
<div
className="detail-mobile-master-panel detail-mobile-master-stats"
id="skill-mobile-master-panel-stats"
role="tabpanel"
aria-labelledby="skill-mobile-master-tab-stats"
hidden={mobileDetailPanel !== "stats"}
>
{mobileStatsContent}
</div>
</div>
</DetailHero>
</>
);
}
function SignedInActionTooltip({
children,
isAuthenticated,
message,
}: {
children: ReactNode;
isAuthenticated: boolean;
message: string;
}) {
if (isAuthenticated) return children;
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side="top" align="center">
{message}
</TooltipContent>
</Tooltip>
);
}
function SkillSidebarDeferredStats({
skill,
owner,
ownerHandle,
formattedStats,
latestVersion,
showArchiveMetadata,
securityAuditSummary,
activityTrend,
activityTrendLoading = false,
hideCreator = false,
}: {
skill: Doc<"skills"> | PublicSkill;
owner: PublicPublisher | null;
ownerHandle: string | null;
formattedStats: ReturnType<typeof formatSkillStatsTriplet>;
latestVersion: SkillHeaderLatestVersion;
showArchiveMetadata: boolean;
securityAuditSummary?: ReactNode;
activityTrend?: ActivityTrend | null;
activityTrendLoading?: boolean;
hideCreator?: boolean;
}) {
const githubRepositoryLink = getGitHubRepositoryLink(skill);
const downloadsMetricBlock = useDownloadsSidebarMetricBlock({
allTimeDownloads: skill.stats.downloads,
activityTrend: activityTrend?.downloads,
loading: activityTrendLoading,
});
return (
<SidebarMetadata
ariaLabel="Skill metadata"
density="compact"
className="skill-sidebar-deferred-metadata"
blocks={[
activityTrend || activityTrendLoading
? downloadsMetricBlock
: {
label: <ActivityMetricLabel label="Downloads" />,
value: formattedStats.downloads,
large: true,
},
{ label: "Repository", value: githubRepositoryLink },
...(hideCreator
? []
: [
{
label: "Creator",
value: (
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix=""
size="md"
showName
showHandle={false}
showMutedHandle
disableTooltip
/>
),
},
]),
securityAuditSummary
? {
key: "security-audit",
label: <DetailSecuritySummaryLabel />,
value: securityAuditSummary,
}
: { label: "", value: null },
...(showArchiveMetadata
? [
{
grid: [
{
label: "Last updated",
value: (
<span title={new Date(skill.updatedAt).toLocaleString()}>
{timeAgo(skill.updatedAt)}
</span>
),
},
{
label: "Current version",
value: latestVersion?.version ? `v${latestVersion.version}` : "None",
},
],
},
{ label: "License", value: PLATFORM_SKILL_LICENSE },
]
: [
{
label: "Last updated",
value: (
<span title={new Date(skill.updatedAt).toLocaleString()}>
{timeAgo(skill.updatedAt)}
</span>
),
},
]),
]}
/>
);
}
+31 -6
View File
@@ -44,6 +44,13 @@ type SkillInstallSurfaceProps = {
ownerHandle: string | null;
ownerId: Id<"users"> | Id<"publishers"> | null;
clawdis?: ClawdisSkillMetadata;
installTarget?: string;
skillPageUrl?: string | null;
secondaryInstall?: {
label: string;
command: string;
copyAriaLabel: string;
};
};
export function SkillInstallSurface({
@@ -52,6 +59,8 @@ export function SkillInstallSurface({
ownerHandle,
ownerId,
clawdis,
installTarget: installTargetOverride,
skillPageUrl: skillPageUrlOverride,
}: SkillInstallSurfaceProps) {
const headingId = useId();
const [promptMode, setPromptMode] = useState<SkillPromptMode>("install-and-setup");
@@ -80,7 +89,8 @@ export function SkillInstallSurface({
const selectedPrompt =
PROMPT_OPTIONS.find((option) => option.mode === promptMode) ?? PROMPT_OPTIONS[1];
const installTarget = buildSkillInstallTarget(ownerHandle, ownerId, slug);
const installTarget =
installTargetOverride ?? buildSkillInstallTarget(ownerHandle, ownerId, slug);
const promptPreview = formatOpenClawPrompt({
mode: promptMode,
skillName: displayName,
@@ -88,6 +98,8 @@ export function SkillInstallSurface({
ownerHandle,
ownerId,
clawdis,
installTarget: installTargetOverride,
skillPageUrl: skillPageUrlOverride,
});
const promptFeedback =
@@ -105,6 +117,8 @@ export function SkillInstallSurface({
ownerHandle,
ownerId,
clawdis,
installTarget: installTargetOverride,
skillPageUrl: skillPageUrlOverride,
});
setPromptMode(mode);
@@ -193,15 +207,24 @@ export function SkillCommandLineCard({
ownerHandle,
ownerId,
clawdis,
installTarget: installTargetOverride,
skillPageUrl: skillPageUrlOverride,
secondaryInstall,
}: SkillInstallSurfaceProps) {
const headingId = useId();
type InstallTab = "cli" | "skills" | "prompt";
const [activeInstallTab, setActiveInstallTab] = useState<InstallTab>("cli");
const [installTabDirection, setInstallTabDirection] = useState<"left" | "right">("right");
const installTarget = buildSkillInstallTarget(ownerHandle, ownerId, slug);
const installTarget =
installTargetOverride ?? buildSkillInstallTarget(ownerHandle, ownerId, slug);
const openClawCommand = formatOpenClawInstallCommand(installTarget);
const skillPageUrl = buildSkillPageUrl(ownerHandle, ownerId, slug);
const skillsCliCommand = skillPageUrl ? formatSkillsCliInstallCommand(skillPageUrl) : null;
const skillPageUrl =
skillPageUrlOverride === undefined
? buildSkillPageUrl(ownerHandle, ownerId, slug)
: skillPageUrlOverride;
const skillsCliCommand =
secondaryInstall?.command ??
(skillPageUrl ? formatSkillsCliInstallCommand(skillPageUrl) : null);
const promptPreview = formatOpenClawPrompt({
mode: "install-and-setup",
skillName: displayName,
@@ -209,6 +232,8 @@ export function SkillCommandLineCard({
ownerHandle,
ownerId,
clawdis,
installTarget: installTargetOverride,
skillPageUrl: skillPageUrlOverride,
});
const activeInstallText =
activeInstallTab === "prompt"
@@ -250,7 +275,7 @@ export function SkillCommandLineCard({
aria-pressed={activeInstallTab === "skills"}
onClick={() => selectInstallTab("skills")}
>
npx skills
{secondaryInstall?.label ?? "npx skills"}
</button>
) : null}
<button
@@ -295,7 +320,7 @@ export function SkillCommandLineCard({
activeInstallTab === "prompt"
? "Copy OpenClaw prompt"
: activeInstallTab === "skills"
? "Copy npx skills command"
? (secondaryInstall?.copyAriaLabel ?? "Copy npx skills command")
: "Copy OpenClaw CLI command"
}
className="skill-install-command-inline-button"
@@ -0,0 +1,61 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { Id } from "../../convex/_generated/dataModel";
import type { PublicSkill } from "../lib/publicUser";
import { SkillRelatedSection } from "./SkillRelatedSection";
describe("SkillRelatedSection", () => {
it("shows only the plain download count in compact related rows", () => {
const { container } = render(
<SkillRelatedSection
category={{
slug: "productivity",
label: "Productivity",
icon: "list-checks",
keywords: [],
}}
relatedSkills={[{ skill: makeSkill(), ownerHandle: "creator" }]}
isLoading={false}
variant="compact"
/>,
);
const stats = screen.getByLabelText("4,321 downloads");
expect(stats.textContent).toBe("4.3k");
expect(stats.querySelector("svg")).toBeNull();
expect(screen.queryByText("654")).toBeNull();
expect(screen.queryByText("7.3k")).toBeNull();
expect(container.querySelector(".lucide-bookmark")).toBeNull();
expect(container.querySelector(".lucide-download")).toBeNull();
});
});
function makeSkill(): PublicSkill {
return {
_id: "skills:related" as Id<"skills">,
_creationTime: 1,
slug: "related",
displayName: "Related Skill",
summary: "A related skill.",
icon: undefined,
ownerUserId: "users:owner" as Id<"users">,
ownerPublisherId: "publishers:owner" as Id<"publishers">,
canonicalSkillId: undefined,
forkOf: undefined,
latestVersionId: undefined,
tags: {},
badges: {},
stats: {
downloads: 4_321,
stars: 654,
versions: 1,
comments: 0,
installs: 7_300,
},
isSuspicious: false,
createdAt: 1,
updatedAt: 1,
};
}
+5 -10
View File
@@ -1,4 +1,3 @@
import { Bookmark, Download } from "lucide-react";
import { BrowseCategoryIcon } from "../lib/browseCategoryIcons";
import { buildSkillCategoryBrowseHref, type SkillCategory } from "../lib/categories";
import { formatSkillStatsTriplet } from "../lib/numberFormat";
@@ -107,15 +106,11 @@ export function SkillRelatedSection({
) : null}
</span>
{isCompact ? (
<span className="related-skill-stats" aria-label="Related skill stats">
<span className="related-skill-stat">
<Bookmark size={13} aria-hidden="true" />
{formattedStats.stars}
</span>
<span className="related-skill-stat">
<Download size={13} aria-hidden="true" />
{formattedStats.installs}
</span>
<span
className="related-skill-stats"
aria-label={`${entry.skill.stats.downloads.toLocaleString()} downloads`}
>
{formattedStats.downloads}
</span>
) : (
<span className="related-skill-owner">
+104 -22
View File
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import type { SkillsShCatalogDetail } from "../lib/skillsShCatalog";
@@ -18,34 +18,67 @@ vi.mock("@tanstack/react-router", () => ({
}) => <a href={`${to}?${new URLSearchParams(search).toString()}`}>{children}</a>,
}));
describe("SkillsShCatalogDetailPage", () => {
it("shows the external trust boundary, upstream checks, provenance, and freshness", () => {
render(<SkillsShCatalogDetailPage entry={makeEntry()} />);
vi.mock("../lib/useHeroCreatorPublisher", () => ({
useHeroCreatorPublisher: ({ owner }: { owner?: unknown }) => owner,
}));
expect(screen.getAllByText("Not scanned by ClawHub").length).toBeGreaterThan(0);
expect(screen.getByText("Gen Agent Trust Hub")).toBeTruthy();
expect(screen.getByText("Socket")).toBeTruthy();
expect(screen.getByText("Snyk")).toBeTruthy();
expect(screen.getByText("Upstream checks are separate from ClawHub scanning.")).toBeTruthy();
expect(screen.getAllByText("Observed 1m ago").length).toBeGreaterThan(0);
expect(screen.getByRole("link", { name: /View on skills\.sh/i }).getAttribute("href")).toBe(
describe("SkillsShCatalogDetailPage", () => {
it("shows skills.sh provenance and simple security audit rows", () => {
const { container } = render(<SkillsShCatalogDetailPage entry={makeEntry()} />);
expect(screen.queryByText("Not scanned by ClawHub")).toBeNull();
expect(screen.queryByText("ClawHub")).toBeNull();
expect(screen.getAllByText("Gen Agent Trust Hub").length).toBeGreaterThan(0);
expect(screen.getAllByText("Socket").length).toBeGreaterThan(0);
expect(screen.getAllByText("Snyk").length).toBeGreaterThan(0);
expect(screen.queryByText("Separate from ClawHub scanning.")).toBeNull();
expect(screen.queryByText("Observed")).toBeNull();
expect(screen.queryByText("Trust")).toBeNull();
expect(screen.queryByText("Path")).toBeNull();
expect(screen.queryByRole("link", { name: /View on skills\.sh/i })).toBeNull();
expect(screen.getByRole("link", { name: "skills.sh" }).getAttribute("href")).toBe(
"https://skills.sh/patrick-erichsen/skills/html",
);
expect(screen.getByText("Synced from").closest("a")).toBeNull();
expect(screen.getAllByText("100").length).toBeGreaterThan(0);
expect(screen.getAllByText("Downloads").length).toBeGreaterThan(0);
expect(screen.queryByText("Upstream installs")).toBeNull();
expect(screen.getByText("HTML Artifact Chooser Build useful artifacts.")).toBeTruthy();
expect(container.querySelector(".skill-hero-layout.has-sidebar")).toBeTruthy();
expect(container.querySelector(".skill-hero-sidebar")?.textContent).toContain(
"Security Audits",
);
const audits = screen.getByRole("region", { name: "Security Audits" });
expect(audits.querySelectorAll(".skills-sh-security-audit-row")).toHaveLength(3);
expect(audits.querySelector("h2")?.classList.contains("sidebar-metadata-label")).toBe(true);
const verdicts = audits.querySelectorAll(".skills-sh-security-audit-verdict");
expect(verdicts).toHaveLength(3);
expect(verdicts[1]?.className).toContain("bg-status-success-bg");
expect(verdicts[2]?.className).toContain("bg-status-warning-bg");
expect(container.querySelector(".skills-sh-detail-trust-alert")).toBeNull();
expect(container.querySelector(".skills-sh-detail-source-badge")).toBeNull();
});
it("shows colon-form install commands and a preselected GitHub Skill Sync claim", () => {
it("shows the GitHub owner, repository, and a preselected GitHub Skill Sync claim", () => {
render(<SkillsShCatalogDetailPage entry={makeEntry()} />);
expect(
screen.getByText("openclaw skills install skills-sh:patrick-erichsen/skills/html", {
exact: true,
}),
).toBeTruthy();
expect(
screen.getByText("clawhub install skills-sh:patrick-erichsen/skills/html", { exact: true }),
).toBeTruthy();
expect(screen.getByText("openclaw skills install")).toBeTruthy();
expect(screen.getByText("skills-sh:patrick-erichsen/skills/html")).toBeTruthy();
expect(screen.queryByRole("button", { name: "ClawHub" })).toBeNull();
const owner = screen.getByRole("link", { name: "View openclaw profile" });
expect(owner.getAttribute("href")).toBe("https://github.com/openclaw");
expect(owner.querySelector("img")?.getAttribute("src")).toBe(
"https://github.com/openclaw.png?size=96",
);
const repository = screen.getByRole("link", { name: "openclaw/openclaw" });
expect(repository.getAttribute("href")).toBe(
"https://github.com/openclaw/openclaw/tree/050daba89f6b6636470add5cb300aac46a412cf8/skills/html",
);
expect(repository.classList.contains("plugin-external-link")).toBe(true);
expect(repository.querySelector("svg")).toBeTruthy();
expect(screen.queryByRole("link", { name: "View on skills.sh" })).toBeNull();
const claimUrl = new URL(
screen.getByRole("link", { name: "Claim" }).getAttribute("href") ?? "",
screen.getByRole("link", { name: "Claim this skill" }).getAttribute("href") ?? "",
"https://clawhub.test",
);
expect(claimUrl.pathname).toBe("/settings");
@@ -64,13 +97,51 @@ describe("SkillsShCatalogDetailPage", () => {
it("renders only stored bounded content and no file explorer", () => {
render(<SkillsShCatalogDetailPage entry={makeEntry()} />);
expect(screen.getByRole("heading", { name: "Stored SKILL.md" })).toBeTruthy();
const detailTabs = screen.getByRole("tablist", { name: "Skill detail tabs" });
expect(detailTabs).toBeTruthy();
expect(detailTabs.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toBe(
"SKILL.md",
);
expect(screen.getByRole("heading", { name: "Use this skill" })).toBeTruthy();
expect(screen.queryByText("Files")).toBeNull();
expect(screen.queryByText("File explorer")).toBeNull();
expect(screen.queryByText("skills/html/SKILL.md")).toBeNull();
expect(screen.getByText("Content is truncated to the stored 64 KiB snapshot.")).toBeTruthy();
});
it("uses the normal skill install card with the exact skills.sh reference", () => {
const { container } = render(<SkillsShCatalogDetailPage entry={makeEntry()} />);
expect(container.querySelector(".skill-install-command-card")).toBeTruthy();
expect(screen.getByText("openclaw skills install")).toBeTruthy();
expect(screen.getByText("skills-sh:patrick-erichsen/skills/html")).toBeTruthy();
expect(screen.getByRole("link", { name: "skills.sh" })).toBeTruthy();
});
it("uses the shared skill detail shell for content and stats", () => {
render(<SkillsShCatalogDetailPage entry={makeEntry()} />);
const sectionTabs = screen.getByRole("tablist", { name: "Skill mobile sections" });
expect(sectionTabs).toBeTruthy();
expect(sectionTabs.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toBe(
"SKILL.md",
);
expect(screen.getByRole("link", { name: "html", current: "page" }).getAttribute("href")).toBe(
"/skills-sh/patrick-erichsen/skills/html",
);
const breadcrumbs = screen.getByRole("navigation", { name: "Skill breadcrumbs" });
expect(breadcrumbs.querySelector("a[href*='skills.sh']")).toBeNull();
fireEvent.click(screen.getByRole("tab", { name: "Stats & details" }));
expect(screen.getByRole("tab", { name: "Stats & details" }).getAttribute("aria-selected")).toBe(
"true",
);
expect(screen.getAllByText("Security Audits").length).toBeGreaterThan(0);
expect(screen.queryByRole("button", { name: "Bookmark skill" })).toBeNull();
expect(screen.queryByRole("button", { name: "Report" })).toBeNull();
});
it("hides install commands without a commit-pinned GitHub folder", () => {
const entry = makeEntry();
delete entry.githubCommit;
@@ -79,6 +150,16 @@ describe("SkillsShCatalogDetailPage", () => {
expect(screen.queryByText(/^openclaw skills install /)).toBeNull();
expect(screen.queryByText(/^clawhub install /)).toBeNull();
});
it("keeps ownerless upstream entries labeled as skills.sh", () => {
const entry = makeEntry();
delete entry.owner;
render(<SkillsShCatalogDetailPage entry={entry} />);
const breadcrumbs = screen.getByRole("navigation", { name: "Skill breadcrumbs" });
expect(breadcrumbs.textContent).toContain("skills.sh");
expect(breadcrumbs.textContent).not.toContain("patrick-erichsen/skills");
});
});
function makeEntry(): SkillsShCatalogDetail {
@@ -91,6 +172,7 @@ function makeEntry(): SkillsShCatalogDetail {
repo: "skills",
slug: "html",
displayName: "HTML Artifact Chooser",
summary: "# HTML Artifact Chooser **Build useful artifacts.**",
categories: ["development"],
topics: [],
sourceUrl: "https://skills.sh/patrick-erichsen/skills/html",
+277 -208
View File
@@ -1,243 +1,312 @@
import { Link } from "@tanstack/react-router";
import { BadgeCheck, ShieldAlert } from "lucide-react";
import { getSkillCategoriesForSkill } from "../lib/categories";
import { formatCompactStat } from "../lib/numberFormat";
import {
CheckCircle2,
CircleHelp,
ExternalLink,
GitBranch,
ShieldAlert,
TriangleAlert,
XCircle,
} from "lucide-react";
import {
buildSkillsShInstallCommands,
isSkillsShCatalogInstallable,
SKILLS_SH_TRUST_LABEL,
skillsShRepositoryLabel,
type SkillsShCatalogDetail,
type SkillsShUpstreamCheck,
} from "../lib/skillsShCatalog";
import { timeAgo } from "../lib/timeAgo";
import { InstallCopyButton } from "./InstallCopyButton";
import { Container } from "./layout/Container";
import { truncateText } from "../lib/truncateText";
import { MarkdownPreview } from "./MarkdownPreview";
import { SidebarMetadata } from "./SidebarMetadata";
import { SkillDetailPageView, type SkillDetailViewSkill } from "./SkillDetailPageView";
import { SkillCommandLineCard } from "./SkillInstallSurface";
import { Alert, AlertDescription } from "./ui/alert";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { UserBadge } from "./UserBadge";
const CHECK_PRESENTATION = {
passed: { Icon: CheckCircle2, className: "text-status-success-fg" },
warning: { Icon: TriangleAlert, className: "text-status-warning-fg" },
failed: { Icon: XCircle, className: "text-status-error-fg" },
unavailable: { Icon: CircleHelp, className: "text-ink-soft" },
passed: "success",
warning: "warning",
failed: "destructive",
unavailable: "compact",
} as const;
function GitHubIcon({ size = 14 }: { size?: number }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" width={size} height={size} aria-hidden="true">
<path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.1.79-.25.79-.56 0-.28-.01-1.02-.02-2-3.2.7-3.88-1.54-3.88-1.54-.52-1.33-1.28-1.69-1.28-1.69-1.05-.72.08-.7.08-.7 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.55-.29-5.24-1.28-5.24-5.68 0-1.25.45-2.28 1.18-3.08-.12-.29-.51-1.46.11-3.04 0 0 .97-.31 3.16 1.18.92-.26 1.9-.38 2.88-.39.98 0 1.96.13 2.88.39 2.19-1.49 3.15-1.18 3.15-1.18.63 1.58.24 2.75.12 3.04.74.8 1.18 1.83 1.18 3.08 0 4.42-2.69 5.39-5.25 5.67.42.36.78 1.07.78 2.15 0 1.55-.01 2.8-.01 3.18 0 .31.21.67.8.56A11.51 11.51 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z" />
</svg>
);
}
function skillsShSummary(summary: string | undefined) {
if (!summary) return "Agent-ready skill pack from skills.sh.";
const plain = summary
.replace(/!\[([^\]]*)\]\([^)]+\)/gu, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/gu, "$1")
.replace(/^[#>]+\s*/gmu, "")
.replace(/[*_`~]/gu, "")
.replace(/\s+/gu, " ")
.trim();
return truncateText(plain, 280);
}
function pinnedGitHubSourceUrl(entry: SkillsShCatalogDetail) {
const repositoryUrl = `https://github.com/${entry.canonicalGitHubRepo}`;
if (!entry.githubCommit) return entry.canonicalRepoUrl ?? repositoryUrl;
if (!entry.githubPath) return `${repositoryUrl}/tree/${entry.githubCommit}`;
const encodedPath = entry.githubPath.split("/").map(encodeURIComponent).join("/");
return `${repositoryUrl}/tree/${entry.githubCommit}/${encodedPath}`;
}
export function SkillsShCatalogDetailPage({ entry }: { entry: SkillsShCatalogDetail }) {
const installable = isSkillsShCatalogInstallable(entry);
const githubOwner = entry.canonicalGitHubRepo.split("/")[0] ?? entry.canonicalGitHubRepo;
const skill: SkillDetailViewSkill = {
slug: entry.slug,
displayName: entry.displayName,
summary: skillsShSummary(entry.summary),
icon: null,
...(installable
? { installKind: "github" as const, githubSourceRepo: entry.canonicalGitHubRepo }
: {}),
categories: entry.categories,
inferredCategories: [],
topics: entry.topics,
badges: {},
stats: {
downloads: entry.upstreamInstalls,
stars: 0,
installs: entry.upstreamInstalls,
versions: 0,
comments: 0,
},
updatedAt: entry.lastObservedAt,
};
const installContent = installable ? (
<SkillCommandLineCard
slug={entry.slug}
displayName={entry.displayName}
ownerHandle={entry.owner ?? null}
ownerId={null}
installTarget={entry.reference}
skillPageUrl={null}
/>
) : (
<Alert variant="warn">
<ShieldAlert aria-hidden="true" size={17} />
<AlertDescription>
This snapshot does not include a commit-pinned GitHub folder, so it cannot be installed yet.
</AlertDescription>
</Alert>
);
return (
<main className="py-10 sm:py-14">
<Container size="narrow">
<div className="flex flex-col gap-5 border-b border-[color:var(--oc-border-subtle)] pb-7 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="break-all font-mono text-xs text-[color:var(--oc-text-muted)]">
{entry.reference}
</p>
<h1 className="mt-2 font-display text-3xl font-black leading-tight text-[color:var(--oc-text-primary)] sm:text-4xl">
{entry.displayName}
</h1>
{entry.summary ? (
<p className="mt-3 max-w-2xl text-sm leading-6 text-[color:var(--oc-text-secondary)] sm:text-base">
{entry.summary}
</p>
) : null}
</div>
<Badge variant="warning" className="shrink-0 self-start">
<ShieldAlert aria-hidden="true" size={15} /> {SKILLS_SH_TRUST_LABEL}
</Badge>
</div>
<Alert variant="warn" className="mt-7">
<ShieldAlert aria-hidden="true" size={17} />
<AlertDescription>
This is a stored upstream skills.sh listing. ClawHub has not scanned or accepted this
source.
</AlertDescription>
</Alert>
<dl className="grid grid-cols-1 gap-x-8 gap-y-6 py-7 sm:grid-cols-2">
<DetailField label="Source" value={skillsShRepositoryLabel(entry)} mono />
<DetailField label="Freshness" value={`Observed ${timeAgo(entry.lastObservedAt)}`} />
{entry.githubPath ? <DetailField label="Path" value={entry.githubPath} mono /> : null}
{entry.githubCommit ? (
<DetailField label="Commit" value={entry.githubCommit} mono />
) : null}
</dl>
<div className="flex flex-wrap gap-3 border-b border-[color:var(--oc-border-subtle)] pb-7">
<SkillDetailPageView
pageClassName="skills-sh-detail-page"
skill={skill}
owner={null}
ownerHandle={entry.owner ?? null}
latestVersion={null}
modInfo={null}
canManage={false}
isAuthenticated={false}
isStaff={false}
isStarred={false}
onToggleStar={() => undefined}
onOpenReport={() => undefined}
onRequireSignIn={() => undefined}
forkOf={null}
forkOfLabel="fork of"
forkOfHref={null}
forkOfOwnerHandle={null}
canonical={null}
canonicalHref={null}
canonicalOwnerHandle={null}
staffVisibilityTag={null}
isAutoHidden={false}
isRemoved={false}
nixPlugin={undefined}
hasPluginBundle={false}
configRequirements={undefined}
cliHelp={undefined}
clawdis={undefined}
categories={getSkillCategoriesForSkill(skill)}
showArchiveMetadata={false}
showBookmarkAction={false}
showReportAction={false}
taxonomyPrefix={
<span className="skills-sh-sync-source-label">
Synced from{" "}
<a
className="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--oc-accent-primary)] hover:underline"
className="skills-sh-sync-source"
href={entry.sourceUrl}
target="_blank"
rel="noreferrer"
>
View on skills.sh <ExternalLink aria-hidden="true" size={14} />
skills.sh
</a>
{entry.canonicalRepoUrl ? (
<a
className="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--oc-text-secondary)] hover:underline"
href={entry.canonicalRepoUrl}
target="_blank"
rel="noreferrer"
>
View repository <ExternalLink aria-hidden="true" size={14} />
</a>
) : null}
</div>
<section className="border-b border-[color:var(--oc-border-subtle)] py-7">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="font-display text-lg font-bold text-[color:var(--oc-text-primary)]">
Install
</h2>
{entry.githubPath && entry.githubCommit && entry.githubContentHash ? (
<Button asChild variant="outline" size="sm">
<Link
to="/settings"
search={{
view: "githubSources",
ownerHandle: entry.canonicalGitHubRepo.split("/")[0],
repo: entry.canonicalGitHubRepo,
sourceRepo: entry.canonicalGitHubRepo,
sourceExternalId: entry.externalId,
sourcePath: entry.githubPath,
sourceCommit: entry.githubCommit,
sourceContentHash: entry.githubContentHash,
}}
>
<GitBranch size={15} aria-hidden="true" /> Claim
</Link>
</Button>
) : null}
</div>
{installable ? (
<div className="mt-3 grid gap-3">
{buildSkillsShInstallCommands(entry.reference).map(({ client, command }) => (
<div key={client}>
<p className="mb-1 text-xs font-semibold text-[color:var(--oc-text-muted)]">
{client}
</p>
<div className="flex min-w-0 items-center gap-2 rounded-[var(--oc-radius-inset)] border border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-bg-surface)] p-2 pl-3">
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-sm text-[color:var(--oc-text-primary)]">
{command}
</code>
<InstallCopyButton
text={command}
ariaLabel={`Copy ${client} install command`}
showLabel={false}
variant="ghost"
size="icon-sm"
/>
</div>
</div>
))}
</div>
) : (
<Alert variant="warn" className="mt-3">
<ShieldAlert aria-hidden="true" size={17} />
<AlertDescription>
This snapshot does not include a commit-pinned GitHub folder, so it cannot be
installed yet.
</AlertDescription>
</Alert>
)}
</section>
<section className="border-b border-[color:var(--oc-border-subtle)] py-7">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="font-display text-lg font-bold text-[color:var(--oc-text-primary)]">
Upstream checks
</h2>
<p className="text-xs text-[color:var(--oc-text-muted)]">
Upstream checks are separate from ClawHub scanning.
</p>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
{entry.upstreamChecks.map((check) => (
<UpstreamCheck key={check.scanner} check={check} />
))}
</div>
</section>
{entry.content ? (
<section className="pt-7">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="font-display text-lg font-bold text-[color:var(--oc-text-primary)]">
Stored {entry.content.kind === "skill-md" ? "SKILL.md" : "README"}
</h2>
<code className="break-all text-xs text-[color:var(--oc-text-muted)]">
{entry.content.path}
</code>
</div>
{entry.content.truncated ? (
<p className="mt-2 text-xs text-[color:var(--oc-text-muted)]">
Content is truncated to the stored 64 KiB snapshot.
</p>
) : null}
<MarkdownPreview className="mt-5">{entry.content.markdown}</MarkdownPreview>
</section>
) : null}
</Container>
</main>
</span>
}
breadcrumbOwnerHref={null}
breadcrumbOwnerLabel={entry.owner ?? "skills.sh"}
breadcrumbSkillHref={entry.route}
creatorContent={
<UserBadge
user={{
handle: githubOwner,
displayName: githubOwner,
image: `https://github.com/${githubOwner}.png?size=96`,
}}
fallbackHandle={githubOwner}
prefix=""
size="md"
showName
showHandle={false}
showMutedHandle
stackMutedHandleBelowName
disableTooltip
profileHref={`https://github.com/${githubOwner}`}
/>
}
installContent={installContent}
renderSidebarContent={() => <SkillsShSidebar entry={entry} />}
>
<SkillsShContentTabs entry={entry} />
</SkillDetailPageView>
);
}
function DetailField({
label,
value,
mono = false,
}: {
label: string;
value: string;
mono?: boolean;
}) {
function SkillsShSidebar({ entry }: { entry: SkillsShCatalogDetail }) {
return (
<div className="min-w-0">
<dt className="text-xs font-semibold text-[color:var(--oc-text-muted)]">{label}</dt>
<dd
className={`mt-1 break-all text-sm text-[color:var(--oc-text-primary)]${mono ? " font-mono" : ""}`}
<div className="skill-hero-sidebar-stack">
<SidebarMetadata
ariaLabel="skills.sh metadata"
density="compact"
blocks={[
{
label: "Downloads",
value: (
<span title={`${entry.upstreamInstalls.toLocaleString()} downloads`}>
{formatCompactStat(entry.upstreamInstalls)}
</span>
),
large: true,
},
{
label: "Repository",
value: (
<a
href={pinnedGitHubSourceUrl(entry)}
target="_blank"
rel="noreferrer"
className="plugin-external-link"
>
<GitHubIcon />
{entry.canonicalGitHubRepo}
</a>
),
},
...(entry.githubCommit
? [
{
label: "Commit",
value: <code>{entry.githubCommit.slice(0, 12)}</code>,
},
]
: []),
]}
/>
<section className="skills-sh-security-audits" aria-label="Security Audits">
<h2 className="sidebar-metadata-label">Security Audits</h2>
<div className="skills-sh-security-audit-list">
{entry.upstreamChecks.map((check) => (
<UpstreamCheck key={check.scanner} check={check} />
))}
</div>
</section>
<div className="skills-sh-detail-links">
{entry.githubPath && entry.githubCommit && entry.githubContentHash ? (
<Button asChild variant="outline" size="sm">
<Link
to="/settings"
search={{
view: "githubSources",
ownerHandle: entry.canonicalGitHubRepo.split("/")[0],
repo: entry.canonicalGitHubRepo,
sourceRepo: entry.canonicalGitHubRepo,
sourceExternalId: entry.externalId,
sourcePath: entry.githubPath,
sourceCommit: entry.githubCommit,
sourceContentHash: entry.githubContentHash,
}}
>
<BadgeCheck size={15} aria-hidden="true" /> Claim this skill
</Link>
</Button>
) : null}
</div>
</div>
);
}
function SkillsShContentTabs({ entry }: { entry: SkillsShCatalogDetail }) {
return (
<div className="tab-card detail-mobile-tabs skill-detail-tabs-card">
<div className="tab-header" role="tablist" aria-label="Skill detail tabs">
<button
id="skill-tab-readme"
className="tab-button is-active"
type="button"
role="tab"
aria-selected="true"
aria-controls="skill-tabpanel-readme"
>
{entry.content?.kind === "readme" ? "README" : "SKILL.md"}
</button>
</div>
<div
className="tab-body skill-readme-body"
role="tabpanel"
id="skill-tabpanel-readme"
aria-labelledby="skill-tab-readme"
>
{value}
</dd>
{entry.content ? (
<>
{entry.content.truncated ? (
<p className="skills-sh-content-note">
Content is truncated to the stored 64 KiB snapshot.
</p>
) : null}
<div className="skill-readme-preview">
<MarkdownPreview highlight={false}>{entry.content.markdown}</MarkdownPreview>
</div>
</>
) : (
<div className="empty-state px-[var(--space-4)] py-[var(--space-6)]">
<p className="empty-state-title">No stored content available</p>
<p className="empty-state-body">This skills.sh listing has no stored Markdown.</p>
</div>
)}
</div>
</div>
);
}
function UpstreamCheck({ check }: { check: SkillsShUpstreamCheck }) {
const presentation = CHECK_PRESENTATION[check.status];
const Icon = presentation.Icon;
return (
<div className="rounded-[var(--oc-radius-inset)] border border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-bg-surface)] px-3 py-3">
<div className="flex items-center gap-2">
<Icon aria-hidden="true" size={15} className={presentation.className} />
<span className="text-sm font-semibold text-[color:var(--oc-text-primary)]">
{check.scanner}
</span>
</div>
<p className={`mt-1 text-xs font-medium ${presentation.className}`}>{check.sourceStatus}</p>
{check.checkedAt ? (
<p className="mt-1 text-xs text-[color:var(--oc-text-muted)]">
Checked {timeAgo(check.checkedAt)}
</p>
) : null}
{check.url ? (
<a
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-[color:var(--oc-accent-primary)] hover:underline"
href={check.url}
target="_blank"
rel="noreferrer"
>
View result <ExternalLink aria-hidden="true" size={12} />
</a>
) : null}
</div>
const content = (
<>
<span>{check.scanner}</span>
<Badge
variant={CHECK_PRESENTATION[check.status]}
size="sm"
className="skills-sh-security-audit-verdict"
>
{check.sourceStatus}
</Badge>
</>
);
return check.url ? (
<a className="skills-sh-security-audit-row" href={check.url} target="_blank" rel="noreferrer">
{content}
</a>
) : (
<div className="skills-sh-security-audit-row">{content}</div>
);
}
+4 -1
View File
@@ -33,6 +33,7 @@ type UserBadgeProps = {
/** Hero creator row: stack `@handle` below the display name. */
stackMutedHandleBelowName?: boolean;
disableTooltip?: boolean;
profileHref?: string | null;
};
export function UserBadge({
@@ -46,12 +47,14 @@ export function UserBadge({
showMutedHandle = false,
stackMutedHandleBelowName = false,
disableTooltip = false,
profileHref,
}: UserBadgeProps) {
const userName =
hasOwnProperty(user, "name") && typeof user.name === "string" ? user.name.trim() : undefined;
const displayName = user?.displayName?.trim() || userName || null;
const handle = user?.handle ?? fallbackHandle ?? null;
const href = handle ? buildPublisherProfileHref(handle) : null;
const href =
profileHref === undefined ? (handle ? buildPublisherProfileHref(handle) : null) : profileHref;
const label = handle ? `@${handle}` : "user";
const image = user?.image ?? null;
const showStackedMutedHandle =
+7 -2
View File
@@ -19,6 +19,8 @@ type SkillPromptContext = {
ownerHandle: string | null;
ownerId: SkillOwnerId | null;
clawdis?: ClawdisSkillMetadata;
installTarget?: string;
skillPageUrl?: string | null;
};
export function buildSkillHref(
@@ -205,9 +207,12 @@ export function formatOpenClawPrompt({
ownerHandle,
ownerId,
clawdis,
installTarget,
skillPageUrl,
}: SkillPromptContext) {
const target = buildSkillInstallTarget(ownerHandle, ownerId, slug);
const pageUrl = buildSkillPageUrl(ownerHandle, ownerId, slug);
const target = installTarget?.trim() || buildSkillInstallTarget(ownerHandle, ownerId, slug);
const pageUrl =
skillPageUrl === undefined ? buildSkillPageUrl(ownerHandle, ownerId, slug) : skillPageUrl;
const displayName = skillName.trim() || slug;
const requiredEnvVars = new Set(clawdis?.requires?.env ?? []);
-7
View File
@@ -98,13 +98,6 @@ export function skillsShRepositoryLabel(result: SkillsShSearchResult) {
return result.sourceHost ?? "skills.sh";
}
export function buildSkillsShInstallCommands(reference: string) {
return [
{ client: "OpenClaw", command: `openclaw skills install ${reference}` },
{ client: "ClawHub", command: `clawhub install ${reference}` },
] as const;
}
export function isSkillsShCatalogInstallable(
detail: Pick<SkillsShCatalogDetail, "githubCommit" | "githubContentHash" | "githubPath">,
) {
+21
View File
@@ -16,6 +16,13 @@ export type CanonicalTrendingItem = {
image: string | null;
official: boolean;
} | null;
sourceIdentity?: {
id: string;
owner: string | null;
repo: string | null;
host: string | null;
lifetimeInstalls: number | null;
};
official: boolean;
featured: boolean;
metrics: {
@@ -88,6 +95,19 @@ function isCanonicalPublisher(value: unknown): value is CanonicalTrendingItem["p
);
}
function isCanonicalSourceIdentity(
value: unknown,
): value is NonNullable<CanonicalTrendingItem["sourceIdentity"]> {
if (!isRecord(value)) return false;
return (
typeof value.id === "string" &&
isNullableString(value.owner) &&
isNullableString(value.repo) &&
isNullableString(value.host) &&
isNullableNumber(value.lifetimeInstalls)
);
}
function isCanonicalTrendingItem(value: unknown): value is LegacyCanonicalTrendingItem {
if (!isRecord(value) || !isRecord(value.metrics)) return false;
return (
@@ -98,6 +118,7 @@ function isCanonicalTrendingItem(value: unknown): value is LegacyCanonicalTrendi
isNullableString(value.summary) &&
isCanonicalPath(value.canonicalUrl) &&
isCanonicalPublisher(value.publisher) &&
(value.sourceIdentity === undefined || isCanonicalSourceIdentity(value.sourceIdentity)) &&
typeof value.official === "boolean" &&
typeof value.featured === "boolean" &&
(value.metrics.trending24hDownloads === undefined ||
+131 -9
View File
@@ -7059,13 +7059,35 @@ code {
.skill-hero-taxonomy-row {
display: inline-flex;
align-items: center;
gap: var(--space-5);
gap: var(--space-3);
min-width: 0;
color: color-mix(in srgb, var(--ink-soft) 76%, var(--bg));
font-size: 12px;
line-height: 1;
}
.skill-hero-taxonomy-prefix {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
}
.skills-sh-sync-source-label {
color: inherit;
font-weight: 520;
}
.skills-sh-sync-source {
color: inherit;
text-decoration: underline;
text-underline-offset: 3px;
}
.skills-sh-sync-source:hover,
.skills-sh-sync-source:focus-visible {
color: var(--ink);
}
.skill-category-meta-list {
display: inline-flex;
align-items: center;
@@ -7328,6 +7350,70 @@ code {
font-size: 0.8rem;
}
.skills-sh-security-audits {
display: grid;
gap: 8px;
padding: 18px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.skills-sh-security-audits h2 {
margin: 0;
color: var(--ink-soft);
font-family: inherit;
font-size: 0.86rem;
font-weight: 700;
line-height: 1.2;
}
.skills-sh-security-audit-list {
display: grid;
}
.skills-sh-security-audit-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 36px;
padding: 8px 0;
color: var(--ink);
font-size: 0.8rem;
font-weight: 600;
text-decoration: none;
}
.skills-sh-security-audit-verdict {
flex: 0 0 auto;
border-radius: var(--oc-radius-control);
text-transform: uppercase;
}
a.skills-sh-security-audit-row:hover,
a.skills-sh-security-audit-row:focus-visible {
color: var(--ink);
text-decoration: none;
background: var(--hover-bg);
}
.skills-sh-detail-links {
display: grid;
gap: 8px;
}
.skills-sh-detail-links .btn {
justify-content: flex-start;
width: 100%;
}
.skills-sh-content-note {
margin: 0;
padding-bottom: 14px;
color: var(--ink-soft);
font-size: 0.72rem;
}
.skill-sidebar-actions {
display: grid;
gap: 10px;
@@ -8801,6 +8887,28 @@ code {
overflow-x: hidden;
}
.skills-sh-detail-page .detail-mobile-install .skill-install-command-shell-cli {
align-items: flex-start;
}
.skills-sh-detail-page .detail-mobile-install .skill-install-command-prompt {
align-self: flex-start;
}
.skills-sh-detail-page
.detail-mobile-install
.skill-install-command-shell-cli
.skill-install-command,
.skills-sh-detail-page
.detail-mobile-install
.skill-install-command-shell-cli
.skill-install-command
code {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.detail-mobile-install .skill-install-command-shell-cli .skill-install-command,
.detail-mobile-install .skill-install-command-shell-cli .skill-install-command code {
white-space: nowrap;
@@ -22739,7 +22847,7 @@ a.search-empty-action {
.home-v2-listing-head {
--home-v2-listing-copy-max: min(54rem, 60vw);
display: grid;
grid-template-columns: auto minmax(0, var(--home-v2-listing-copy-max)) 1fr auto;
grid-template-columns: minmax(0, var(--home-v2-listing-copy-max)) 1fr auto;
gap: 16px 20px;
align-items: center;
padding: 0 4px 8px;
@@ -22748,7 +22856,7 @@ a.search-empty-action {
}
.home-v2-listing-head.has-no-stats {
grid-template-columns: auto minmax(0, var(--home-v2-listing-copy-max)) 1fr;
grid-template-columns: minmax(0, var(--home-v2-listing-copy-max)) 1fr;
}
.home-v2-listing-head-icon-spacer {
@@ -22767,11 +22875,11 @@ a.search-empty-action {
}
.home-v2-listing-head-label {
grid-column: 2;
grid-column: 1;
}
.home-v2-listing-head-stat {
grid-column: 4;
grid-column: 3;
justify-self: end;
}
@@ -23035,7 +23143,7 @@ a.search-empty-action {
--home-v2-listing-copy-max: min(54rem, 60vw);
position: relative;
display: grid;
grid-template-columns: auto minmax(0, var(--home-v2-listing-copy-max)) 1fr auto;
grid-template-columns: minmax(0, var(--home-v2-listing-copy-max)) 1fr auto;
gap: 16px 20px;
align-items: center;
padding: 13px 4px;
@@ -23046,7 +23154,7 @@ a.search-empty-action {
}
.home-v2-listing-row.has-no-stats {
grid-template-columns: auto minmax(0, var(--home-v2-listing-copy-max)) 1fr;
grid-template-columns: minmax(0, var(--home-v2-listing-copy-max)) 1fr;
}
.home-v2-listing-row::before {
@@ -23132,7 +23240,7 @@ a.search-empty-action {
}
.home-v2-listing-row-body {
grid-column: 2;
grid-column: 1;
min-width: 0;
max-width: var(--home-v2-listing-copy-max);
display: grid;
@@ -23182,6 +23290,20 @@ a.search-empty-action {
white-space: nowrap;
}
.home-v2-listing-source-badge {
flex: 0 0 auto;
padding: 2px 6px;
border: 1px solid color-mix(in srgb, var(--hv2-text-tertiary) 42%, transparent);
border-radius: var(--r-pill);
color: var(--hv2-text-tertiary);
font-family: var(--font-mono), ui-monospace, monospace;
font-size: 9px;
font-weight: 650;
line-height: 1.2;
letter-spacing: 0.02em;
white-space: nowrap;
}
.home-v2-listing-row-summary {
margin: 0;
font-size: 13px;
@@ -23209,7 +23331,7 @@ a.search-empty-action {
}
.home-v2-listing-row-stats {
grid-column: 4;
grid-column: 3;
display: flex;
align-items: center;
justify-content: flex-end;