From 2f428b4e1b1afb2618b5188e12c685d26074dd57 Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Thu, 6 Aug 2026 17:17:16 -0300 Subject: [PATCH] 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 --- specs/README.md | 1 + .../2026-08-06-discovery-icon-hierarchy.md | 18 +++ src/__tests__/home-listing-section.test.tsx | 20 +++- src/__tests__/packages-route.test.tsx | 2 + src/__tests__/skills-index.claw591.test.tsx | 2 + src/__tests__/skills-index.test.tsx | 106 ++++++++++++----- src/__tests__/ui-design-contract.test.ts | 31 +++++ src/components/HomeListingSection.tsx | 26 ++++- src/components/SkillCard.tsx | 20 ++-- src/components/SkillListItem.tsx | 13 ++- .../skeletons/BrowseResultsSkeleton.test.tsx | 28 +++++ .../skeletons/BrowseResultsSkeleton.tsx | 25 ++-- src/routes/plugins/index.tsx | 4 +- src/routes/skills/-SkillsResults.tsx | 27 +++-- src/styles.css | 108 +++++++++++++++++- 15 files changed, 366 insertions(+), 65 deletions(-) create mode 100644 specs/regression-notes/2026-08-06-discovery-icon-hierarchy.md create mode 100644 src/components/skeletons/BrowseResultsSkeleton.test.tsx diff --git a/specs/README.md b/specs/README.md index 9b868c17..9ae1ab13 100644 --- a/specs/README.md +++ b/specs/README.md @@ -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. diff --git a/specs/regression-notes/2026-08-06-discovery-icon-hierarchy.md b/specs/regression-notes/2026-08-06-discovery-icon-hierarchy.md new file mode 100644 index 00000000..fcf6287a --- /dev/null +++ b/specs/regression-notes/2026-08-06-discovery-icon-hierarchy.md @@ -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. diff --git a/src/__tests__/home-listing-section.test.tsx b/src/__tests__/home-listing-section.test.tsx index e5a50b44..d7df71e6 100644 --- a/src/__tests__/home-listing-section.test.tsx +++ b/src/__tests__/home-listing-section.test.tsx @@ -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(".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(); + + 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(); diff --git a/src/__tests__/packages-route.test.tsx b/src/__tests__/packages-route.test.tsx index 957b3e0a..c9a1eb7f 100644 --- a/src/__tests__/packages-route.test.tsx +++ b/src/__tests__/packages-route.test.tsx @@ -911,6 +911,7 @@ describe("plugins route", () => { render(); + expect(screen.getByRole("main").className).toContain("plugins-browse-page"); expect(screen.getByText("1.2k")).toBeTruthy(); }); @@ -920,6 +921,7 @@ describe("plugins route", () => { render(); + 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(); diff --git a/src/__tests__/skills-index.claw591.test.tsx b/src/__tests__/skills-index.claw591.test.tsx index ce66c5ae..e78993c0 100644 --- a/src/__tests__/skills-index.claw591.test.tsx +++ b/src/__tests__/skills-index.claw591.test.tsx @@ -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 () => { diff --git a/src/__tests__/skills-index.test.tsx b/src/__tests__/skills-index.test.tsx index 6d5f1c14..465af9c1 100644 --- a/src/__tests__/skills-index.test.tsx +++ b/src/__tests__/skills-index.test.tsx @@ -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(); - 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(); + 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(); @@ -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(); + 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(); - await act(async () => {}); + render(); + 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 = { diff --git a/src/__tests__/ui-design-contract.test.ts b/src/__tests__/ui-design-contract.test.ts index b079f93a..0446bcee 100644 --- a/src/__tests__/ui-design-contract.test.ts +++ b/src/__tests__/ui-design-contract.test.ts @@ -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(); diff --git a/src/components/HomeListingSection.tsx b/src/components/HomeListingSection.tsx index eaf0bb8d..71aab58a 100644 --- a/src/components/HomeListingSection.tsx +++ b/src/components/HomeListingSection.tsx @@ -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 ( - + +
@@ -464,7 +474,13 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
{activeStatus === "idle" && activeItems.length > 0 ? ( -