mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
fix: align discovery icons by content type and viewport (#3427)
Remove Skill listing icons across discovery, retain Plugin recognition icons on desktop, and collapse both icon columns at the existing mobile breakpoints. Keep loading skeletons aligned with settled rows and cards. Closes #3425. Co-authored-by: Vyctor H. Brzezowski <krzyszchweski@gmail.com>
This commit is contained in:
@@ -35,4 +35,5 @@ into `docs/` and leave only the design record here.
|
||||
- `webhook.md`: Discord webhook environment and payload notes.
|
||||
- `plans/plugins.md`: long-term OpenClaw plugin hosting plan.
|
||||
- `regression-notes/`: regression guard notes.
|
||||
- `2026-08-06-discovery-icon-hierarchy.md`: discovery icon policy across content types, viewports, and loading states.
|
||||
- `superpowers/`: install-surface design history.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Discovery Icon Hierarchy
|
||||
|
||||
Date: 2026-08-06
|
||||
|
||||
ClawHub discovery listings use icons selectively to preserve useful recognition without adding repetitive visual noise.
|
||||
|
||||
The protected matrix is:
|
||||
|
||||
| Content | Desktop | Mobile |
|
||||
| ------- | -------- | -------- |
|
||||
| Skills | No icons | No icons |
|
||||
| Plugins | Icons | No icons |
|
||||
|
||||
This applies to homepage discovery, dedicated Skills and Plugins browse routes, list and grid layouts, and their loading skeletons. Skeletons must reserve an icon column only where loaded content will render one.
|
||||
|
||||
Plugin icons remain useful on desktop because they help users recognize services and integrations. Skill icons are omitted because the catalog does not use them as a meaningful recognition signal. Mobile omits both icon types to prioritize package identity and metadata in the available width.
|
||||
|
||||
Intentional changes to this matrix must update the focused discovery tests and this note in the same PR.
|
||||
@@ -168,10 +168,26 @@ describe("HomeListingSection", () => {
|
||||
expect(screen.getByText("Demo Plugin")).toBeTruthy();
|
||||
expect(document.querySelector(".home-v2-listing-list")).toBeTruthy();
|
||||
expect(screen.getByText("Downloads")).toBeTruthy();
|
||||
expect(document.querySelector(".home-v2-listing-row-icon")).toBeNull();
|
||||
expect(document.querySelectorAll(".home-v2-listing-row-icon")).toHaveLength(1);
|
||||
expect(
|
||||
document
|
||||
.querySelector<HTMLImageElement>(".home-v2-listing-row-icon img")
|
||||
?.getAttribute("src"),
|
||||
).toBe(featuredPlugin.icon);
|
||||
expect(document.querySelector(".home-v2-listing-row-stats svg")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the initial Skills skeleton iconless", () => {
|
||||
fetchCatalogDiscoveryCapabilitiesMock.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
render(<HomeListingSection />);
|
||||
|
||||
const loadingResults = screen.getByRole("status", { name: "Loading results" });
|
||||
expect(loadingResults.querySelector(".browse-results-skeleton-icon")).toBeNull();
|
||||
expect(loadingResults.querySelector(".browse-list-head-icon-spacer")).toBeNull();
|
||||
expect(loadingResults.querySelectorAll(".skill-list-item-no-icon")).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("previews long skill and plugin names while retaining their full labels", async () => {
|
||||
const skillName = "S".repeat(71);
|
||||
const pluginName = "P".repeat(71);
|
||||
@@ -261,6 +277,8 @@ describe("HomeListingSection", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText("Initial Skill")).toBeTruthy();
|
||||
expect(document.querySelector(".home-v2-listing-row-icon")).toBeNull();
|
||||
expect(document.querySelector(".marketplace-icon-skill")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Load more" })).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(convexQueryMock).not.toHaveBeenCalled();
|
||||
|
||||
@@ -911,6 +911,7 @@ describe("plugins route", () => {
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByRole("main").className).toContain("plugins-browse-page");
|
||||
expect(screen.getByText("1.2k")).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -920,6 +921,7 @@ describe("plugins route", () => {
|
||||
|
||||
render(<PendingComponent />);
|
||||
|
||||
expect(screen.getByRole("main").className).toContain("plugins-browse-page");
|
||||
expect(screen.getByRole("heading", { name: "Plugins" })).toBeTruthy();
|
||||
expect(screen.getByRole("status", { name: "Loading results" })).toBeTruthy();
|
||||
expect(screen.queryByText("Loading results")).toBeNull();
|
||||
|
||||
@@ -140,6 +140,7 @@ describe("SkillsIndex", () => {
|
||||
expect(screen.queryByText("8K")).toBeNull();
|
||||
expect(screen.queryByText("skills.sh")).toBeNull();
|
||||
expect(screen.queryByText(/Not scanned by ClawHub/i)).toBeNull();
|
||||
expect(document.querySelector(".marketplace-icon-skill")).toBeNull();
|
||||
});
|
||||
|
||||
it("labels canonical Trending grid cards as downloads", async () => {
|
||||
@@ -153,6 +154,7 @@ describe("SkillsIndex", () => {
|
||||
expect(await screen.findByText("First Skill")).toBeTruthy();
|
||||
expect(screen.getByLabelText("24-hour downloads").textContent).toContain("17");
|
||||
expect(screen.queryByLabelText("24-hour installs")).toBeNull();
|
||||
expect(document.querySelector(".marketplace-icon-skill")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows skills.sh provenance without presenting lifetime installs as downloads", async () => {
|
||||
|
||||
@@ -410,16 +410,28 @@ describe("SkillsIndex", () => {
|
||||
expect(screen.queryByRole("button", { name: "Publish" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows loading state before fetch completes", async () => {
|
||||
// Never resolve the query to keep the component in loading state
|
||||
convexHttpMock.query.mockReturnValue(new Promise(() => {}));
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
// Results area shows skeletons while loading, without count copy.
|
||||
expect(screen.queryByText(/\d+ loaded/)).toBeNull();
|
||||
expect(screen.getByRole("status", { name: "Loading results" })).toBeTruthy();
|
||||
expect(screen.queryByText("No skills found")).toBeNull();
|
||||
});
|
||||
it.each(["list", "grid"] as const)(
|
||||
"shows an iconless %s loading state before fetch completes",
|
||||
async (view) => {
|
||||
searchMock = { tab: "new", view: view === "grid" ? view : undefined };
|
||||
// Never resolve the query to keep the component in loading state
|
||||
convexHttpMock.query.mockReturnValue(new Promise(() => {}));
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
// Results area shows skeletons while loading, without count copy.
|
||||
expect(screen.queryByText(/\d+ loaded/)).toBeNull();
|
||||
const loadingResults = screen.getByRole("status", { name: "Loading results" });
|
||||
expect(loadingResults.querySelector(".browse-results-skeleton-icon")).toBeNull();
|
||||
expect(loadingResults.querySelector(".browse-list-head-icon-spacer")).toBeNull();
|
||||
expect(loadingResults.querySelectorAll(".skill-card-header-no-icon")).toHaveLength(
|
||||
view === "grid" ? 6 : 0,
|
||||
);
|
||||
expect(loadingResults.querySelectorAll(".skill-list-item-no-icon")).toHaveLength(
|
||||
view === "list" ? 6 : 0,
|
||||
);
|
||||
expect(screen.queryByText("No skills found")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("uses grid as the canonical browse view URL value", async () => {
|
||||
render(<SkillsIndex />);
|
||||
@@ -802,6 +814,30 @@ describe("SkillsIndex", () => {
|
||||
);
|
||||
expect(titles).toEqual(["Native Find", "Find Skills"]);
|
||||
expect(screen.getByText("skills.sh")).toBeTruthy();
|
||||
expect(document.querySelector(".marketplace-icon-skill")).toBeNull();
|
||||
expect(document.querySelector(".browse-list-head-icon-spacer")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps native and external grid results free of skill icons", async () => {
|
||||
searchMock = { q: "find skills", view: "grid" };
|
||||
convexReactMocks.useAction.mockReturnValue(
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
makeSearchResult("native-find", "Native Find", 6_000, 2_000),
|
||||
makeExternalSearchResult("vercel-labs/skills/find-skills", "Find Skills", 5_000),
|
||||
]),
|
||||
);
|
||||
vi.useFakeTimers();
|
||||
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Native Find")).toBeTruthy();
|
||||
expect(screen.getByText("Find Skills")).toBeTruthy();
|
||||
expect(document.querySelector(".marketplace-icon-skill")).toBeNull();
|
||||
});
|
||||
|
||||
it("includes results explicitly assigned to the selected category", async () => {
|
||||
@@ -1105,28 +1141,40 @@ describe("SkillsIndex", () => {
|
||||
expect(screen.queryByText("No skills found")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows skeletons during load-more", async () => {
|
||||
vi.stubGlobal("IntersectionObserver", undefined);
|
||||
convexHttpMock.query
|
||||
.mockResolvedValueOnce({
|
||||
page: [makeListResult("skill-0", "Skill 0")],
|
||||
hasMore: true,
|
||||
nextCursor: "cursor-1",
|
||||
})
|
||||
// Second call (load more) never resolves
|
||||
.mockReturnValueOnce(new Promise(() => {}));
|
||||
it.each(["list", "grid"] as const)(
|
||||
"shows iconless %s skeletons during load-more",
|
||||
async (view) => {
|
||||
vi.stubGlobal("IntersectionObserver", undefined);
|
||||
searchMock = { tab: "new", view: view === "grid" ? view : undefined };
|
||||
convexHttpMock.query
|
||||
.mockResolvedValueOnce({
|
||||
page: [makeListResult("skill-0", "Skill 0")],
|
||||
hasMore: true,
|
||||
nextCursor: "cursor-1",
|
||||
})
|
||||
// Second call (load more) never resolves
|
||||
.mockReturnValueOnce(new Promise(() => {}));
|
||||
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
|
||||
const loadMoreButton = screen.getByRole("button", { name: "Load more" });
|
||||
await act(async () => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
const loadMoreButton = screen.getByRole("button", { name: "Load more" });
|
||||
await act(async () => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
|
||||
expect(screen.getByRole("status", { name: "Loading results" })).toBeTruthy();
|
||||
expect(screen.queryByText(/Loading/)).toBeNull();
|
||||
});
|
||||
const loadingResults = screen.getByRole("status", { name: "Loading results" });
|
||||
expect(loadingResults.querySelector(".browse-results-skeleton-icon")).toBeNull();
|
||||
expect(loadingResults.querySelector(".browse-list-head-icon-spacer")).toBeNull();
|
||||
expect(loadingResults.querySelectorAll(".skill-card-header-no-icon")).toHaveLength(
|
||||
view === "grid" ? 2 : 0,
|
||||
);
|
||||
expect(loadingResults.querySelectorAll(".skill-list-item-no-icon")).toHaveLength(
|
||||
view === "list" ? 2 : 0,
|
||||
);
|
||||
expect(screen.queryByText(/Loading/)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
type NavigateSearchCall = {
|
||||
|
||||
@@ -141,6 +141,37 @@ describe("restored UI design contract", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("removes discovery icon columns on mobile while preserving plugin icons on desktop", () => {
|
||||
const css = styles();
|
||||
|
||||
expect(cssRule(css, ".home-v2-listing-row-with-icon")).toContain("grid-template-columns: auto");
|
||||
expect(cssRule(css, ".browse-page .browse-results-grid .skill-card-header")).toContain(
|
||||
"grid-template-columns: auto",
|
||||
);
|
||||
|
||||
const homeMobile = cssMediaContaining(css, "(max-width: 768px)", [
|
||||
".home-v2-listing-row.home-v2-listing-row-with-icon",
|
||||
".home-v2-listing .browse-results-skeleton-icon",
|
||||
]);
|
||||
expect(homeMobile).toContain(
|
||||
"grid-template-columns: minmax(0, var(--home-v2-listing-copy-max)) 1fr auto;",
|
||||
);
|
||||
expect(homeMobile).toMatch(
|
||||
/\.home-v2-listing-head-with-icon \.home-v2-listing-head-icon-spacer,\s*\.home-v2-listing-row-with-icon \.home-v2-listing-row-icon,\s*\.home-v2-listing \.browse-list-head-icon-spacer,\s*\.home-v2-listing \.browse-results-skeleton-icon\s*\{\s*display:\s*none;/,
|
||||
);
|
||||
|
||||
const browseMobile = cssMediaContaining(css, "(max-width: 760px)", [
|
||||
".plugins-browse-page .browse-results-grid .skill-card-header > .marketplace-icon",
|
||||
".browse-page .browse-results-skeleton-icon",
|
||||
]);
|
||||
expect(browseMobile).toMatch(
|
||||
/\.browse-page \.skill-list-item > \.marketplace-icon,\s*\.skills-browse-page \.browse-results-grid \.skill-card-header > \.marketplace-icon,\s*\.plugins-browse-page \.browse-results-grid \.skill-card-header > \.marketplace-icon,\s*\.browse-page \.browse-results-skeleton-icon\s*\{\s*display:\s*none;/,
|
||||
);
|
||||
expect(browseMobile).toMatch(
|
||||
/\.skills-browse-page \.browse-results-grid \.skill-card-header,\s*\.plugins-browse-page \.browse-results-grid \.skill-card-header\s*\{\s*grid-template-columns:\s*minmax\(0, 1fr\);/,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps dashboard package names inside their rows and attention cards", () => {
|
||||
const css = styles();
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ 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";
|
||||
import { Badge } from "./ui/badge";
|
||||
@@ -192,7 +193,16 @@ function HomeListingPluginRow({ plugin }: { plugin: PackageListItem }) {
|
||||
const pluginHref = buildPluginDetailHref(plugin.name, { ownerHandle: plugin.ownerHandle });
|
||||
|
||||
return (
|
||||
<Link to={pluginHref} className="home-v2-listing-row">
|
||||
<Link to={pluginHref} className="home-v2-listing-row home-v2-listing-row-with-icon">
|
||||
<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}>
|
||||
@@ -464,7 +474,13 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
</div>
|
||||
|
||||
{activeStatus === "idle" && activeItems.length > 0 ? (
|
||||
<div className="home-v2-listing-head" aria-hidden="true">
|
||||
<div
|
||||
className={`home-v2-listing-head${
|
||||
kind === "plugins" ? " home-v2-listing-head-with-icon" : ""
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{kind === "plugins" ? <span className="home-v2-listing-head-icon-spacer" /> : null}
|
||||
<span className="home-v2-listing-head-label">
|
||||
{kind === "skills" ? "Skill" : "Plugin"}
|
||||
</span>
|
||||
@@ -473,7 +489,11 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
) : null}
|
||||
|
||||
{activeStatus === "loading" ? (
|
||||
<BrowseResultsSkeleton label={kind === "skills" ? "Skill" : "Plugin"} variant="list" />
|
||||
<BrowseResultsSkeleton
|
||||
label={kind === "skills" ? "Skill" : "Plugin"}
|
||||
showIcon={kind === "plugins"}
|
||||
variant="list"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeStatus === "error" ? <HomeListingEmptyPanel variant="error" /> : null}
|
||||
|
||||
@@ -21,6 +21,7 @@ type SkillCardProps = {
|
||||
className?: string;
|
||||
ownerHandle?: string | null;
|
||||
owner?: PublicPublisher | null;
|
||||
showIcon?: boolean;
|
||||
};
|
||||
|
||||
export function SkillCard({
|
||||
@@ -34,6 +35,7 @@ export function SkillCard({
|
||||
className,
|
||||
ownerHandle,
|
||||
owner,
|
||||
showIcon = true,
|
||||
}: SkillCardProps) {
|
||||
const ownerSegment = encodeURIComponent(String(skill.ownerUserId));
|
||||
const link = href ?? `/${ownerSegment}/${skill.slug}`;
|
||||
@@ -53,14 +55,16 @@ export function SkillCard({
|
||||
|
||||
return (
|
||||
<Link to={link} className={["card skill-card", className].filter(Boolean).join(" ")}>
|
||||
<div className="skill-card-header">
|
||||
<MarketplaceIcon
|
||||
kind="skill"
|
||||
label={displayName}
|
||||
imageUrl={skill.icon}
|
||||
skill={skill}
|
||||
size="md"
|
||||
/>
|
||||
<div className={`skill-card-header${showIcon ? "" : " skill-card-header-no-icon"}`}>
|
||||
{showIcon ? (
|
||||
<MarketplaceIcon
|
||||
kind="skill"
|
||||
label={displayName}
|
||||
imageUrl={skill.icon}
|
||||
skill={skill}
|
||||
size="md"
|
||||
/>
|
||||
) : null}
|
||||
<div className="skill-card-identity">
|
||||
<h3 className="skill-card-title" title={displayName}>
|
||||
{truncateText(displayName, PUBLIC_CATALOG_NAME_PREVIEW_LENGTH)}
|
||||
|
||||
@@ -18,6 +18,7 @@ type SkillListItemProps = {
|
||||
owner?: PublicPublisher | null;
|
||||
href?: string;
|
||||
showOfficialBadge?: boolean;
|
||||
showIcon?: boolean;
|
||||
};
|
||||
|
||||
export function SkillListItem({
|
||||
@@ -26,6 +27,7 @@ export function SkillListItem({
|
||||
owner,
|
||||
href: hrefOverride,
|
||||
showOfficialBadge = true,
|
||||
showIcon = true,
|
||||
}: SkillListItemProps) {
|
||||
const handle = ownerHandle ?? owner?.handle ?? null;
|
||||
const ownerSegment = handle?.trim() || String(skill.ownerPublisherId ?? skill.ownerUserId);
|
||||
@@ -41,8 +43,15 @@ export function SkillListItem({
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<Link to={href} className="skill-list-item skill-list-item-skill skill-list-item-with-taxonomy">
|
||||
<MarketplaceIcon kind="skill" label={displayName} imageUrl={skill.icon} skill={skill} />
|
||||
<Link
|
||||
to={href}
|
||||
className={`skill-list-item skill-list-item-skill skill-list-item-with-taxonomy${
|
||||
showIcon ? "" : " skill-list-item-no-icon"
|
||||
}`}
|
||||
>
|
||||
{showIcon ? (
|
||||
<MarketplaceIcon kind="skill" label={displayName} imageUrl={skill.icon} skill={skill} />
|
||||
) : null}
|
||||
<div className="skill-list-item-body">
|
||||
<div className="skill-list-item-main">
|
||||
<span className="skill-list-item-identity">
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BrowseResultsSkeleton } from "./BrowseResultsSkeleton";
|
||||
|
||||
describe("BrowseResultsSkeleton", () => {
|
||||
it.each(["list", "grid"] as const)(
|
||||
"preserves icon placeholders by default for %s consumers",
|
||||
(variant) => {
|
||||
render(<BrowseResultsSkeleton count={2} label="Plugin" variant={variant} />);
|
||||
|
||||
const loadingResults = screen.getByRole("status", { name: "Loading results" });
|
||||
if (variant === "list") {
|
||||
expect(loadingResults.querySelector(".browse-list-head-icon-spacer")).not.toBeNull();
|
||||
expect(loadingResults.querySelectorAll(".browse-results-skeleton-icon")).toHaveLength(2);
|
||||
expect(loadingResults.querySelector(".skill-list-item-no-icon")).toBeNull();
|
||||
} else {
|
||||
expect(loadingResults.querySelectorAll(".skill-card-header")).toHaveLength(2);
|
||||
expect(loadingResults.querySelectorAll(".browse-results-skeleton-icon")).toHaveLength(2);
|
||||
expect(loadingResults.querySelector(".skill-card-header-no-icon")).toBeNull();
|
||||
for (const header of loadingResults.querySelectorAll(".skill-card-header")) {
|
||||
expect(header.children).toHaveLength(2);
|
||||
expect(header.children[1]?.classList.contains("skill-card-identity")).toBe(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { Skeleton } from "../ui/skeleton";
|
||||
type BrowseResultsSkeletonProps = {
|
||||
count?: number;
|
||||
label?: string;
|
||||
showIcon?: boolean;
|
||||
variant?: "list" | "grid";
|
||||
showColumnHead?: boolean;
|
||||
};
|
||||
@@ -10,6 +11,7 @@ type BrowseResultsSkeletonProps = {
|
||||
export function BrowseResultsSkeleton({
|
||||
count = 6,
|
||||
label = "Skill",
|
||||
showIcon = true,
|
||||
variant = "list",
|
||||
showColumnHead = true,
|
||||
}: BrowseResultsSkeletonProps) {
|
||||
@@ -22,8 +24,10 @@ export function BrowseResultsSkeleton({
|
||||
key={i}
|
||||
className="card skill-card skill-card-spaced-footer"
|
||||
>
|
||||
<div className="skill-card-header">
|
||||
<Skeleton className="h-[34px] w-[34px] rounded-[var(--oc-radius-inset)]" />
|
||||
<div className={`skill-card-header${showIcon ? "" : " skill-card-header-no-icon"}`}>
|
||||
{showIcon ? (
|
||||
<Skeleton className="browse-results-skeleton-icon h-[34px] w-[34px] rounded-[var(--oc-radius-inset)]" />
|
||||
) : null}
|
||||
<div className="skill-card-identity">
|
||||
<Skeleton className="h-5 w-40 max-w-full" />
|
||||
<Skeleton className="h-4 w-24 max-w-full" />
|
||||
@@ -48,10 +52,13 @@ export function BrowseResultsSkeleton({
|
||||
return (
|
||||
<div className="browse-list-stack" role="status" aria-label="Loading results">
|
||||
{showColumnHead ? (
|
||||
<div className="browse-list-head" aria-hidden="true">
|
||||
<span className="browse-list-head-icon-spacer" />
|
||||
<div
|
||||
className={`browse-list-head${showIcon ? "" : " browse-list-head-no-icon"}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{showIcon ? <span className="browse-list-head-icon-spacer" /> : null}
|
||||
<span className="browse-list-head-label">{label}</span>
|
||||
<span className="browse-list-head-label">Category</span>
|
||||
<span className="browse-list-head-label browse-list-head-category">Category</span>
|
||||
<span className="browse-list-head-label browse-list-head-stat">Popularity</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -60,9 +67,13 @@ export function BrowseResultsSkeleton({
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholder count
|
||||
key={i}
|
||||
className="skill-list-item skill-list-item-has-creator browse-results-skeleton-row"
|
||||
className={`skill-list-item skill-list-item-has-creator browse-results-skeleton-row${
|
||||
showIcon ? "" : " skill-list-item-no-icon"
|
||||
}`}
|
||||
>
|
||||
<Skeleton className="browse-results-skeleton-icon h-[27px] w-[27px] shrink-0 rounded-[var(--oc-radius-inset)]" />
|
||||
{showIcon ? (
|
||||
<Skeleton className="browse-results-skeleton-icon h-[27px] w-[27px] shrink-0 rounded-[var(--oc-radius-inset)]" />
|
||||
) : null}
|
||||
<div className="skill-list-item-body">
|
||||
<div className="skill-list-item-main">
|
||||
<Skeleton className="h-5 w-32 max-w-[45%]" />
|
||||
|
||||
@@ -334,7 +334,7 @@ export const Route = createFileRoute("/plugins/")({
|
||||
|
||||
function PluginsIndexPending() {
|
||||
return (
|
||||
<main className="browse-page browse-page-borderless-header">
|
||||
<main className="browse-page browse-page-borderless-header plugins-browse-page">
|
||||
<div className="browse-page-header">
|
||||
<h1 className="browse-title">Plugins</h1>
|
||||
</div>
|
||||
@@ -673,7 +673,7 @@ function PluginsIndex() {
|
||||
}, [canLoadMore, loadMore]);
|
||||
|
||||
return (
|
||||
<main className="browse-page browse-page-borderless-header">
|
||||
<main className="browse-page browse-page-borderless-header plugins-browse-page">
|
||||
<div className="browse-page-header">
|
||||
<div className="browse-page-header-main">
|
||||
<h1 className="browse-title">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Download, ExternalLink, Plus } from "lucide-react";
|
||||
import type { RefObject } from "react";
|
||||
import { MarketplaceIcon } from "../../components/MarketplaceIcon";
|
||||
import { BrowseResultsSkeleton } from "../../components/skeletons/BrowseResultsSkeleton";
|
||||
import { SkillCard } from "../../components/SkillCard";
|
||||
import { SkillListItem } from "../../components/SkillListItem";
|
||||
@@ -44,8 +43,10 @@ function TrendingSkillListItem({ item }: { item: TrendingSkillListEntry }) {
|
||||
const trending = item.trending;
|
||||
const owner = trending.publisher?.handle;
|
||||
return (
|
||||
<Link to={trending.canonicalUrl} className="skill-list-item skill-list-item-skill">
|
||||
<MarketplaceIcon kind="skill" label={trending.displayName} />
|
||||
<Link
|
||||
to={trending.canonicalUrl}
|
||||
className="skill-list-item skill-list-item-skill skill-list-item-no-icon skill-list-item-simple-no-icon"
|
||||
>
|
||||
<div className="skill-list-item-body">
|
||||
<div className="skill-list-item-main">
|
||||
<span className="skill-list-item-identity">
|
||||
@@ -94,7 +95,6 @@ function TrendingSkillCard({ item }: { item: TrendingSkillListEntry }) {
|
||||
className="card flex min-w-0 flex-col gap-3 p-5 transition-colors hover:border-[color:var(--oc-border-strong)]"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<MarketplaceIcon kind="skill" label={trending.displayName} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate font-semibold text-[color:var(--oc-text-primary)]">
|
||||
{trending.displayName}
|
||||
@@ -139,11 +139,10 @@ function ExternalSkillSearchListItem({ result }: { result: SkillSearchEntry }) {
|
||||
return (
|
||||
<a
|
||||
href={result.canonicalUrl}
|
||||
className="skill-list-item skill-list-item-skill skill-list-item-with-taxonomy"
|
||||
className="skill-list-item skill-list-item-skill skill-list-item-with-taxonomy skill-list-item-no-icon"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<MarketplaceIcon kind="skill" label={result.displayName} />
|
||||
<div className="skill-list-item-body">
|
||||
<div className="skill-list-item-main">
|
||||
<span className="skill-list-item-identity">
|
||||
@@ -189,7 +188,6 @@ function ExternalSkillSearchCard({ result }: { result: SkillSearchEntry }) {
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<MarketplaceIcon kind="skill" label={result.displayName} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate font-semibold text-[color:var(--oc-text-primary)]">
|
||||
@@ -233,7 +231,7 @@ export function SkillsResults({
|
||||
return (
|
||||
<>
|
||||
{isLoadingSkills ? (
|
||||
<BrowseResultsSkeleton label="Skill" variant={effectiveView} />
|
||||
<BrowseResultsSkeleton label="Skill" showIcon={false} variant={effectiveView} />
|
||||
) : sorted.length === 0 && listDoneLoading ? (
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">
|
||||
@@ -292,6 +290,7 @@ export function SkillsResults({
|
||||
</div>
|
||||
}
|
||||
owner={entry.owner}
|
||||
showIcon={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -299,12 +298,15 @@ export function SkillsResults({
|
||||
) : (
|
||||
<div className="browse-list-stack">
|
||||
<div
|
||||
className={`browse-list-head${showTrendingLayout ? " browse-list-head-trending" : ""}`}
|
||||
className={`browse-list-head${
|
||||
showTrendingLayout ? " browse-list-head-trending" : " browse-list-head-no-icon"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{showTrendingLayout ? null : <span className="browse-list-head-icon-spacer" />}
|
||||
<span className="browse-list-head-label">Skill</span>
|
||||
{showTrendingLayout ? null : <span className="browse-list-head-label">Category</span>}
|
||||
{showTrendingLayout ? null : (
|
||||
<span className="browse-list-head-label browse-list-head-category">Category</span>
|
||||
)}
|
||||
<span className="browse-list-head-label browse-list-head-stat">
|
||||
{showTrendingLayout ? "24h downloads" : "Popularity"}
|
||||
</span>
|
||||
@@ -327,6 +329,7 @@ export function SkillsResults({
|
||||
skill={skill}
|
||||
ownerHandle={ownerHandle}
|
||||
owner={entry.owner}
|
||||
showIcon={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -336,7 +339,7 @@ export function SkillsResults({
|
||||
|
||||
{isLoadingMore ? (
|
||||
<div ref={canAutoLoad ? loadMoreRef : null} className="mt-4">
|
||||
<BrowseResultsSkeleton count={2} variant={effectiveView} />
|
||||
<BrowseResultsSkeleton count={2} showIcon={false} variant={effectiveView} />
|
||||
</div>
|
||||
) : canLoadMore ? (
|
||||
<div ref={canAutoLoad ? loadMoreRef : null} className="card mt-4 flex justify-center">
|
||||
|
||||
+107
-1
@@ -4150,6 +4150,10 @@ code {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.browse-page .browse-results-grid .skill-card-header-no-icon {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.browse-page .browse-results-grid .skill-card-header .marketplace-icon {
|
||||
--marketplace-icon-accent: var(--ink-soft) !important;
|
||||
--marketplace-icon-wash: transparent !important;
|
||||
@@ -17354,6 +17358,10 @@ a.agentic-risk-finding-title:focus-visible {
|
||||
grid-template-columns: minmax(0, 1fr) 180px 190px;
|
||||
}
|
||||
|
||||
.skill-list-item-simple-no-icon {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.skill-list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
@@ -17646,6 +17654,23 @@ a.agentic-risk-finding-title:focus-visible {
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
.browse-page .browse-list-head-no-icon {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.browse-page .browse-list-head-no-icon .browse-list-head-category {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.browse-page .browse-list-head-no-icon .browse-list-head-label:first-child {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.browse-page .browse-list-head-no-icon .browse-list-head-stat {
|
||||
display: initial;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.browse-page .skill-list-item-main > .catalog-topics {
|
||||
display: none;
|
||||
}
|
||||
@@ -19994,14 +20019,31 @@ body:has(.browse-page-borderless-header) .navbar {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.browse-page .skill-list-item > .marketplace-icon {
|
||||
.browse-page .browse-list-head-no-icon .browse-list-head-category {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.browse-page .browse-list-head-no-icon .browse-list-head-label:first-child {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.browse-page .browse-list-head-no-icon .browse-list-head-stat {
|
||||
display: initial;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.browse-page .skill-list-item > .marketplace-icon,
|
||||
.skills-browse-page .browse-results-grid .skill-card-header > .marketplace-icon,
|
||||
.plugins-browse-page .browse-results-grid .skill-card-header > .marketplace-icon,
|
||||
.browse-page .browse-results-skeleton-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skills-browse-page .browse-results-grid .skill-card-header,
|
||||
.plugins-browse-page .browse-results-grid .skill-card-header {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.browse-page .skill-list-item-body {
|
||||
grid-column: 1;
|
||||
max-width: none;
|
||||
@@ -22855,6 +22897,10 @@ a.search-empty-action {
|
||||
grid-template-columns: minmax(0, var(--home-v2-listing-copy-max)) 1fr;
|
||||
}
|
||||
|
||||
.home-v2-listing-head-with-icon {
|
||||
grid-template-columns: auto minmax(0, var(--home-v2-listing-copy-max)) 1fr auto;
|
||||
}
|
||||
|
||||
.home-v2-listing-head-icon-spacer {
|
||||
grid-column: 1;
|
||||
width: 34px;
|
||||
@@ -22870,6 +22916,14 @@ a.search-empty-action {
|
||||
color: var(--hv2-text-tertiary);
|
||||
}
|
||||
|
||||
.home-v2-listing-head-with-icon .home-v2-listing-head-label {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.home-v2-listing-head-with-icon .home-v2-listing-head-stat {
|
||||
grid-column: 4;
|
||||
}
|
||||
|
||||
.home-v2-listing-head-label {
|
||||
grid-column: 1;
|
||||
}
|
||||
@@ -23153,6 +23207,10 @@ a.search-empty-action {
|
||||
grid-template-columns: minmax(0, var(--home-v2-listing-copy-max)) 1fr;
|
||||
}
|
||||
|
||||
.home-v2-listing-row-with-icon {
|
||||
grid-template-columns: auto minmax(0, var(--home-v2-listing-copy-max)) 1fr auto;
|
||||
}
|
||||
|
||||
.home-v2-listing-row::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -23244,6 +23302,10 @@ a.search-empty-action {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.home-v2-listing-row-with-icon .home-v2-listing-row-body {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.home-v2-listing-row-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -23327,6 +23389,10 @@ a.search-empty-action {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-v2-listing-row-with-icon .home-v2-listing-row-stats {
|
||||
grid-column: 4;
|
||||
}
|
||||
|
||||
.home-v2-listing-row-stats.is-skills-sh {
|
||||
gap: 7px;
|
||||
}
|
||||
@@ -28574,6 +28640,46 @@ a.home-v2-byos-import:focus-visible svg:last-child {
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.home-v2-listing-row.home-v2-listing-row-with-icon,
|
||||
.home-v2-listing-head.home-v2-listing-head-with-icon {
|
||||
grid-template-columns: minmax(0, var(--home-v2-listing-copy-max)) 1fr auto;
|
||||
}
|
||||
|
||||
.home-v2-listing-head-with-icon .home-v2-listing-head-icon-spacer,
|
||||
.home-v2-listing-row-with-icon .home-v2-listing-row-icon,
|
||||
.home-v2-listing .browse-list-head-icon-spacer,
|
||||
.home-v2-listing .browse-results-skeleton-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.home-v2-listing .browse-list-head,
|
||||
.home-v2-listing .browse-results-skeleton-row {
|
||||
--home-v2-listing-copy-max: min(15rem, calc(100vw - 11rem));
|
||||
grid-template-columns: minmax(0, var(--home-v2-listing-copy-max)) 1fr auto;
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
.home-v2-listing .browse-list-head-category,
|
||||
.home-v2-listing .browse-results-skeleton-row .skill-list-item-taxonomy {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.home-v2-listing-head-with-icon .home-v2-listing-head-label,
|
||||
.home-v2-listing-row-with-icon .home-v2-listing-row-body,
|
||||
.home-v2-listing
|
||||
.browse-list-head
|
||||
> .browse-list-head-label:not(.browse-list-head-category):not(.browse-list-head-stat),
|
||||
.home-v2-listing .browse-results-skeleton-row .skill-list-item-body {
|
||||
grid-column: 1;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.home-v2-listing-head-with-icon .home-v2-listing-head-stat,
|
||||
.home-v2-listing-row-with-icon .home-v2-listing-row-stats,
|
||||
.home-v2-listing .browse-list-head-stat,
|
||||
.home-v2-listing .browse-results-skeleton-row .skill-list-item-meta {
|
||||
grid-column: 3;
|
||||
}
|
||||
.home-v2-listing-controls {
|
||||
gap: 14px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
Reference in New Issue
Block a user