feat(publish): prefill short summary from SKILL.md with discovery banner

Auto-populate the publish form short summary from SKILL.md frontmatter
description (metadata or top-level), with a dismissible in-field banner
that nudges authors toward discovery-friendly copy. Reset prefill state
on re-upload, measure banner height for textarea padding, and raise the
summary limit to 300 characters.
This commit is contained in:
vyctorbrzezowski
2026-06-26 12:19:37 -07:00
committed by Patrick Erichsen
parent 556b6729dc
commit 5b1be27f4a
6 changed files with 379 additions and 96 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ const QUALITY_WINDOW_MS = 24 * 60 * 60 * 1000;
const QUALITY_ACTIVITY_LIMIT = 60;
const PLATFORM_SKILL_LICENSE = "MIT-0" as const;
const SECURITY_SCAN_ENQUEUE_BACKUP_DELAY_MS = 15_000;
const MAX_PUBLISH_SUMMARY_LENGTH = 200;
const MAX_PUBLISH_SUMMARY_LENGTH = 300;
type FingerprintFile = { path: string; sha256: string };
type SafePublishFile = PublishVersionArgs["files"][number] & { path: string };
+130
View File
@@ -182,6 +182,136 @@ describe("Upload route", () => {
});
});
it("prefills short summary from SKILL.md description metadata", async () => {
render(<Upload />);
const file = new File(
["---\nname: plain-skill\ndescription: Automate recurring workflows.\n---\n# Plain Skill"],
"SKILL.md",
{ type: "text/markdown" },
);
fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [file] } });
await waitFor(() => {
expect((screen.getByLabelText("Short summary") as HTMLTextAreaElement).value).toBe(
"Automate recurring workflows.",
);
expect(screen.getByText(/^Make it discoverable!$/i)).toBeTruthy();
expect(screen.getByText(/Imported from your SKILL\.md/i)).toBeTruthy();
expect(screen.getByText(/where people decide whether to try your skill/i)).toBeTruthy();
expect(screen.getByText(/For better discovery/i)).toBeTruthy();
expect(screen.getByText(/Say what it does/i)).toBeTruthy();
expect(screen.getByText(/Edits here only affect ClawHub/i)).toBeTruthy();
});
});
it("truncates long SKILL.md descriptions when prefilling short summary", async () => {
render(<Upload />);
const longDescription = "a".repeat(350);
const file = new File(
[`---\nname: long-skill\ndescription: ${longDescription}\n---\n# Long Skill`],
"SKILL.md",
{ type: "text/markdown" },
);
fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [file] } });
await waitFor(() => {
const summary = screen.getByLabelText("Short summary") as HTMLTextAreaElement;
expect(summary.value).toHaveLength(300);
expect(summary.value).toBe("a".repeat(300));
});
});
it("hides the short summary recommendation when dismissed and keeps prefilled text", async () => {
render(<Upload />);
const file = new File(
["---\nname: plain-skill\ndescription: Automate recurring workflows.\n---\n# Plain Skill"],
"SKILL.md",
{ type: "text/markdown" },
);
fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByText(/^Make it discoverable!$/i)).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Dismiss summary recommendation" }));
expect(screen.queryByText(/^Make it discoverable!$/i)).toBeNull();
expect((screen.getByLabelText("Short summary") as HTMLTextAreaElement).value).toBe(
"Automate recurring workflows.",
);
});
it("keeps manual short summary edits when SKILL.md is replaced", async () => {
render(<Upload />);
const firstFile = new File(["---\ndescription: First description.\n---\n# First"], "SKILL.md", {
type: "text/markdown",
});
fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [firstFile] } });
await waitFor(() => {
expect((screen.getByLabelText("Short summary") as HTMLTextAreaElement).value).toBe(
"First description.",
);
});
fireEvent.change(screen.getByLabelText("Short summary"), {
target: { value: "Custom summary." },
});
expect(screen.queryByText(/^Make it discoverable!$/i)).toBeNull();
const secondFile = new File(
["---\ndescription: Second description.\n---\n# Second"],
"SKILL.md",
{ type: "text/markdown" },
);
fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [secondFile] } });
await waitFor(() => {
expect((screen.getByLabelText("Short summary") as HTMLTextAreaElement).value).toBe(
"Custom summary.",
);
});
});
it("prefills short summary again after a new upload following manual edits", async () => {
render(<Upload />);
const firstFile = new File(["---\ndescription: First description.\n---\n# First"], "SKILL.md", {
type: "text/markdown",
});
fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [firstFile] } });
await waitFor(() => {
expect((screen.getByLabelText("Short summary") as HTMLTextAreaElement).value).toBe(
"First description.",
);
});
fireEvent.change(screen.getByLabelText("Short summary"), {
target: { value: "Custom summary." },
});
const secondFile = new File(
["---\ndescription: Second description.\n---\n# Second"],
"SKILL.md",
{ type: "text/markdown" },
);
fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [secondFile] } });
await waitFor(() => {
expect((screen.getByLabelText("Short summary") as HTMLTextAreaElement).value).toBe(
"Second description.",
);
expect(screen.getByText(/^Make it discoverable!$/i)).toBeTruthy();
});
});
it("sends explicit empty metadata arrays when categories and topics are cleared on republish", async () => {
useSearchMock.mockReturnValue({ updateSlug: "categorized-skill" });
useQueryMock.mockImplementation((fn: unknown, args: unknown) => {
+114 -16
View File
@@ -1,12 +1,19 @@
import { Info, Lightbulb, X } from "lucide-react";
import { useLayoutEffect, useRef, useState } from "react";
import { cn } from "../lib/utils";
import { Label } from "./ui/label";
import { Textarea } from "./ui/textarea";
export const SKILL_PUBLISH_SUMMARY_MAX_LENGTH = 200;
export const SKILL_PUBLISH_SUMMARY_MAX_LENGTH = 300;
const BANNER_TEXTAREA_GAP_PX = 24;
type SkillShortSummaryFieldProps = {
id: string;
value: string;
disabled?: boolean;
recommendation?: boolean;
onDismissRecommendation?: () => void;
onChange: (value: string) => void;
};
@@ -14,25 +21,116 @@ export function SkillShortSummaryField({
id,
value,
disabled,
recommendation,
onDismissRecommendation,
onChange,
}: SkillShortSummaryFieldProps) {
const bannerRef = useRef<HTMLDivElement>(null);
const [bannerHeight, setBannerHeight] = useState(0);
useLayoutEffect(() => {
let disconnect: (() => void) | undefined;
if (!recommendation) {
setBannerHeight(0);
} else {
const node = bannerRef.current;
if (node) {
const updateHeight = () => {
setBannerHeight(node.getBoundingClientRect().height);
};
updateHeight();
if (typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(updateHeight);
observer.observe(node);
disconnect = () => observer.disconnect();
}
}
}
return () => {
disconnect?.();
};
}, [recommendation]);
const textareaPaddingBottom =
recommendation && bannerHeight > 0 ? bannerHeight + BANNER_TEXTAREA_GAP_PX : undefined;
return (
<div className="flex flex-col gap-2 col-span-full">
<div className="flex flex-col gap-3 col-span-full">
<Label htmlFor={id}>Short summary</Label>
<p className="text-sm text-[color:var(--ink-soft)]">
Short description shown in cards, search, and previews.
</p>
<Textarea
id={id}
aria-label="Short summary"
rows={3}
value={value}
maxLength={SKILL_PUBLISH_SUMMARY_MAX_LENGTH}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
placeholder="Enter a brief description..."
/>
<span className="text-xs text-[color:var(--ink-soft)]">
<div
className={cn(
"relative",
recommendation &&
"overflow-hidden rounded-[var(--radius-sm)] border border-input-border transition-all duration-[180ms] ease-out focus-within:border-input-focus-border focus-within:shadow-[0_0_0_3px_var(--input-focus-ring)]",
)}
>
<Textarea
id={id}
aria-label="Short summary"
rows={3}
value={value}
maxLength={SKILL_PUBLISH_SUMMARY_MAX_LENGTH}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
placeholder="Enter a brief description..."
className={cn(
recommendation &&
"rounded-none border-0 shadow-none focus:border-transparent focus:shadow-none",
)}
style={
textareaPaddingBottom !== undefined
? { paddingBottom: textareaPaddingBottom }
: undefined
}
/>
{recommendation ? (
<div
ref={bannerRef}
className="pointer-events-none absolute inset-x-0 bottom-0 border-t border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3.5 py-2.5"
role="note"
>
<div className="flex flex-col gap-2">
<div className="flex w-full items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5 font-semibold leading-none text-[#0099ff]">
<Lightbulb className="size-3.5 shrink-0" aria-hidden />
<span>Make it discoverable!</span>
</div>
<button
type="button"
aria-label="Dismiss summary recommendation"
disabled={disabled}
className="pointer-events-auto flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--radius-sm)] text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 disabled:cursor-not-allowed disabled:opacity-50"
onClick={onDismissRecommendation}
>
<X className="size-3.5" aria-hidden />
</button>
</div>
<p className="text-xs leading-relaxed text-[color:var(--ink-soft)]">
Imported from your SKILL.md. Descriptions there are often written for agents
technical and trigger-focused. On ClawHub, this appears in cards and search, where
people decide whether to try your skill.
</p>
<div className="flex flex-col gap-1 text-xs leading-snug sm:flex-row sm:flex-wrap sm:items-center sm:gap-x-2 sm:gap-y-1">
<span className="font-semibold text-[color:var(--ink)]">For better discovery</span>
<span
className="hidden h-3 w-px shrink-0 bg-[color:var(--line)] sm:block"
aria-hidden
/>
<span className="font-medium text-[color:var(--ink)]">
Say what it does · Name who it&apos;s for · Keep it short and jargon-light
</span>
</div>
<p className="mt-2 flex items-start gap-1.5 border-t border-[color:var(--line)]/50 pt-2 text-xs leading-snug text-[color:var(--ink-soft)]">
<Info className="mt-0.5 size-3 shrink-0" aria-hidden />
<span>Edits here only affect ClawHub, not your SKILL.md.</span>
</p>
</div>
</div>
) : null}
</div>
<span className="self-end text-xs text-[color:var(--ink-soft)]">
{value.trim().length}/{SKILL_PUBLISH_SUMMARY_MAX_LENGTH}
</span>
</div>
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
extractSkillFrontmatterDescription,
truncateSkillPublishSummary,
} from "./skillFrontmatter";
describe("extractSkillFrontmatterDescription", () => {
it("reads a top-level description field", () => {
const content = `---
name: demo
description: Automate recurring workflows.
---
# Demo`;
expect(extractSkillFrontmatterDescription(content)).toBe("Automate recurring workflows.");
});
it("prefers metadata.description when present", () => {
const content = `---
name: demo
description: Legacy description.
metadata:
description: Use this skill when the user needs CSV analysis.
---
# Demo`;
expect(extractSkillFrontmatterDescription(content)).toBe(
"Use this skill when the user needs CSV analysis.",
);
});
it("returns undefined when no description is present", () => {
expect(extractSkillFrontmatterDescription("# Demo")).toBeUndefined();
expect(extractSkillFrontmatterDescription("---\nname: demo\n---\n# Demo")).toBeUndefined();
});
});
describe("truncateSkillPublishSummary", () => {
it("keeps short values unchanged", () => {
expect(truncateSkillPublishSummary("Short summary", 300)).toBe("Short summary");
});
it("truncates long values to the publish summary limit", () => {
const longDescription = "a".repeat(350);
expect(truncateSkillPublishSummary(longDescription, 300)).toHaveLength(300);
});
});
+6
View File
@@ -15,6 +15,12 @@ function parseMetadata(value: unknown) {
}
}
export function truncateSkillPublishSummary(value: string, maxLength: number) {
const trimmed = value.trim();
if (!trimmed || trimmed.length <= maxLength) return trimmed;
return trimmed.slice(0, maxLength).trimEnd();
}
export function extractSkillFrontmatterDescription(content: string) {
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalized.startsWith("---")) return undefined;
+81 -79
View File
@@ -12,7 +12,6 @@ import {
CircleX,
ExternalLink,
FolderOpen,
Info,
Lock,
Upload as UploadIcon,
X,
@@ -48,7 +47,10 @@ import { Textarea } from "../../components/ui/textarea";
import { UploadDropzoneDecor } from "../../components/UploadDropzoneDecor";
import { VersionInput } from "../../components/VersionInput";
import { setPostPublishFlash } from "../../lib/postPublishFlash";
import { extractSkillFrontmatterDescription } from "../../lib/skillFrontmatter";
import {
extractSkillFrontmatterDescription,
truncateSkillPublishSummary,
} from "../../lib/skillFrontmatter";
import { getPublicSlugCollision } from "../../lib/slugCollision";
import { expandDroppedItems, expandFilesWithReport } from "../../lib/uploadFiles";
import { useAuthStatus } from "../../lib/useAuthStatus";
@@ -132,7 +134,6 @@ export function Upload() {
tags: false,
license: false,
});
const [metadataPrefillNote, setMetadataPrefillNote] = useState<string | null>(null);
const [version, setVersion] = useState("1.0.0");
const [tags, setTags] = useState("latest");
const [categories, setCategories] = useState<string[]>([]);
@@ -144,6 +145,7 @@ export function Upload() {
const categoriesTouchedRef = useRef(false);
const topicsTouchedRef = useRef(false);
const summaryTouchedRef = useRef(false);
const [summaryRecommendationDismissed, setSummaryRecommendationDismissed] = useState(false);
const [changelogStatus, setChangelogStatus] = useState<"idle" | "loading" | "ready" | "error">(
"idle",
);
@@ -346,7 +348,12 @@ export function Upload() {
useEffect(() => {
if (summaryTouchedRef.current) return;
if (!uploadedSkillSummary) return;
setSummary((current) => (current === uploadedSkillSummary ? current : uploadedSkillSummary));
const nextSummary = truncateSkillPublishSummary(
uploadedSkillSummary,
SKILL_PUBLISH_SUMMARY_MAX_LENGTH,
);
if (!nextSummary) return;
setSummary((current) => (current === nextSummary ? current : nextSummary));
}, [uploadedSkillSummary]);
useEffect(() => {
@@ -609,12 +616,17 @@ export function Upload() {
);
}
function resetSummaryPrefillState() {
summaryTouchedRef.current = false;
setSummaryRecommendationDismissed(false);
}
async function applyExpandedFiles(selected: File[]) {
const report = await expandFilesWithReport(selected);
resetSummaryPrefillState();
setFiles(report.files);
setIgnoredLocalMetadataPaths(report.ignoredLocalMetadataPaths);
setPendingFileRemovalIndex(null);
setMetadataPrefillNote(null);
resetFileInput();
if (updateSlug) return;
@@ -624,17 +636,11 @@ export function Upload() {
const nextSlug = slugFromFolderName(folderName);
const nextDisplayName = displayNameFromFolderName(folderName);
const prefilled: string[] = [];
if (nextSlug && !dirtyFields.slug && !trimmedSlug) {
setSlug(nextSlug);
prefilled.push("slug");
}
if (nextDisplayName && !dirtyFields.displayName && !trimmedName) {
setDisplayName(nextDisplayName);
prefilled.push("display name");
}
if (prefilled.length > 0) {
setMetadataPrefillNote(`Suggested ${prefilled.join(" and ")} from the selected folder.`);
}
}
@@ -651,10 +657,10 @@ export function Upload() {
}
function clearSelectedFiles() {
resetSummaryPrefillState();
setFiles([]);
setIgnoredLocalMetadataPaths([]);
setPendingFileRemovalIndex(null);
setMetadataPrefillNote(null);
resetFileInput();
}
@@ -1045,7 +1051,6 @@ export function Upload() {
}
onChange={(event) => {
markFieldDirty("displayName");
setMetadataPrefillNote(null);
setDisplayName(event.target.value);
}}
placeholder="My skill"
@@ -1067,7 +1072,6 @@ export function Upload() {
className={showSlugStatusIcon ? "pr-10" : undefined}
onChange={(event) => {
markFieldDirty("slug");
setMetadataPrefillNote(null);
setSlug(event.target.value);
}}
placeholder="skill-name"
@@ -1103,20 +1107,46 @@ export function Upload() {
id="skillSummary"
value={summary}
disabled={isSubmitting}
recommendation={
files.length > 0 &&
Boolean(uploadedSkillSummary) &&
!summaryTouchedRef.current &&
!summaryRecommendationDismissed
}
onDismissRecommendation={() => {
setSummaryRecommendationDismissed(true);
}}
onChange={(nextSummary) => {
summaryTouchedRef.current = true;
setSummary(nextSummary);
}}
/>
</div>
{metadataPrefillNote ? (
<p
className="flex items-center gap-1.5 text-sm leading-5 text-[#1f6feb] dark:text-[#8fbdff]"
role="status"
>
<Info className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="leading-5">{metadataPrefillNote}</span>
</p>
<div className="border-t border-[color:var(--line)] pt-5">
<CatalogMetadataFields
kind="skill"
categories={categories}
suggestedCategories={suggestedCategories}
topics={topics}
disabled={isSubmitting}
onCategoriesChange={(nextCategories) => {
categoriesTouchedRef.current = true;
setCategories(nextCategories);
}}
onTopicsChange={(nextTopics) => {
topicsTouchedRef.current = true;
setTopics(nextTopics);
}}
/>
</div>
{visibleMetadataIssues.length > 0 ? (
<ul className="flex flex-col gap-1 list-disc pl-5 text-sm text-[color:var(--ink-soft)]">
{visibleMetadataIssues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
) : null}
<div className="flex flex-col gap-2">
@@ -1153,69 +1183,41 @@ export function Upload() {
) : null}
<InlineValidationMessage id="owner-validation-error" message={ownerIssue} />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="version">Version</Label>
<VersionInput
id="version"
value={version}
aria-invalid={Boolean(versionIssue)}
aria-describedby={versionIssue ? "version-validation-error" : undefined}
onValueChange={(nextVersion) => {
markFieldDirty("version");
setVersion(nextVersion);
}}
placeholder="1.0.0"
/>
<InlineValidationMessage id="version-validation-error" message={versionIssue} />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="tags">Release tags</Label>
<Input
id="tags"
value={tags}
onChange={(event) => {
markFieldDirty("tags");
setTags(event.target.value);
}}
placeholder="latest, stable"
/>
</div>
{visibleMetadataIssues.length > 0 ? (
<ul className="flex flex-col gap-1 list-disc pl-5 text-sm text-[color:var(--ink-soft)]">
{visibleMetadataIssues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
) : null}
</CardContent>
</Card>
<Card>
<CardContent className="gap-4">
<div>
<CardTitle>Catalog metadata</CardTitle>
<p className="text-sm text-[color:var(--ink-soft)]">
Choose browse categories and author topics for this skill.
</p>
<div className="grid gap-x-4 gap-y-4 md:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="version">Version</Label>
<VersionInput
id="version"
value={version}
aria-invalid={Boolean(versionIssue)}
aria-describedby={versionIssue ? "version-validation-error" : undefined}
onValueChange={(nextVersion) => {
markFieldDirty("version");
setVersion(nextVersion);
}}
placeholder="1.0.0"
/>
<InlineValidationMessage id="version-validation-error" message={versionIssue} />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="tags">Release tags</Label>
<Input
id="tags"
value={tags}
onChange={(event) => {
markFieldDirty("tags");
setTags(event.target.value);
}}
placeholder="latest, stable"
/>
</div>
</div>
<CatalogMetadataFields
kind="skill"
categories={categories}
suggestedCategories={suggestedCategories}
topics={topics}
disabled={isSubmitting}
onCategoriesChange={(nextCategories) => {
categoriesTouchedRef.current = true;
setCategories(nextCategories);
}}
onTopicsChange={(nextTopics) => {
topicsTouchedRef.current = true;
setTopics(nextTopics);
}}
/>
</CardContent>
</Card>