Compare commits

...
Author SHA1 Message Date
Peter Steinberger 1b96de5012 Merge branch 'main' into redesign/plugins-page 2026-03-23 03:42:52 -07:00
ImLukeF f44beaa3ab feat: redesign plugins page and skills list view
Redesigned the plugins page with a cleaner toolbar (pill search,
toggle filter buttons) and simplified card layout. Added a proper
table-style list view for skills with skill name, version, summary,
and author avatar columns. Also polished the sort dropdown with a
chevron indicator, added card shadows for better separation, and
tightened up the theme toggle and sign-in button.
2026-03-23 20:41:35 +11:00
4 changed files with 301 additions and 170 deletions
+1 -1
View File
@@ -252,7 +252,7 @@ describe("SkillsIndex", () => {
});
const titles = Array.from(
document.querySelectorAll(".skills-row-title > span:first-child"),
document.querySelectorAll(".skills-table-name > span:first-child"),
).map((node) => node.textContent);
expect(titles[0]).toBe("Older High Score");
+127 -132
View File
@@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { fetchPluginCatalog, type PackageListItem } from "../../lib/packageApi";
import { familyLabel, packageCapabilityLabel } from "../../lib/packageLabels";
import { familyLabel } from "../../lib/packageLabels";
type PluginSearchState = {
q?: string;
@@ -97,90 +97,91 @@ export function PluginsIndex() {
Plugins
</h1>
<p className="section-subtitle" style={{ marginBottom: 0 }}>
Code plugins and bundle plugins for OpenClaw.
Browse the plugin catalog.
</p>
</header>
<div className="card" style={{ display: "grid", gap: 12, marginBottom: 18 }}>
<form
onSubmit={(event) => {
event.preventDefault();
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
}),
});
}}
style={{ display: "grid", gap: 12 }}
>
<form
className="skills-toolbar"
onSubmit={(event) => {
event.preventDefault();
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
}),
});
}}
>
<div className="skills-search">
<input
className="input"
placeholder="Search plugins"
className="skills-search-input"
placeholder="Search plugins"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<select
className="input"
value={search.family ?? ""}
onChange={(event) => {
const value = event.target.value as PluginSearchState["family"] | "";
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
family: value || undefined,
}),
});
}}
>
<option value="">All plugins</option>
<option value="code-plugin">Code plugins</option>
<option value="bundle-plugin">Bundle plugins</option>
</select>
<label className="tag" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<input
type="checkbox"
checked={search.verified ?? false}
onChange={(event) => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
verified: event.target.checked || undefined,
}),
});
}}
/>
Verified only
</label>
<label className="tag" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<input
type="checkbox"
checked={search.executesCode ?? false}
onChange={(event) => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
executesCode: event.target.checked || undefined,
}),
});
}}
/>
Executes code
</label>
<Link className="btn" to="/plugins/new">
Publish Plugin
</Link>
</div>
</form>
</div>
</div>
<div className="skills-toolbar-row">
<select
className="skills-sort"
value={search.family ?? ""}
onChange={(event) => {
const value = event.target.value as PluginSearchState["family"] | "";
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
family: value || undefined,
}),
});
}}
aria-label="Filter by type"
>
<option value="">All plugins</option>
<option value="code-plugin">Code plugins</option>
<option value="bundle-plugin">Bundle plugins</option>
</select>
<button
className="search-filter-button"
type="button"
aria-pressed={search.verified ?? false}
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
verified: prev.verified ? undefined : true,
}),
});
}}
>
Verified
</button>
<button
className="search-filter-button"
type="button"
aria-pressed={search.executesCode ?? false}
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
executesCode: prev.executesCode ? undefined : true,
}),
});
}}
>
Executes code
</button>
<Link className="btn btn-primary" to="/plugins/new">
Publish
</Link>
</div>
</form>
{items.length === 0 ? (
<div className="card">No plugins match that filter.</div>
@@ -192,73 +193,67 @@ export function PluginsIndex() {
key={item.name}
to="/plugins/$name"
params={{ name: item.name }}
className="skill-card"
className="card skill-card"
>
<div className="skill-card-tags">
<span className="tag">{familyLabel(item.family)}</span>
{item.executesCode ? (
<span className="tag tag-accent">
{packageCapabilityLabel(item.family, item.executesCode)}
<span className="tag tag-compact">{familyLabel(item.family)}</span>
{item.isOfficial ? (
<span className="tag tag-compact tag-accent">
<VerifiedBadge /> Verified
</span>
) : null}
</div>
<div className="skill-card-title">{item.displayName}</div>
<div className="skills-row-slug">{item.name}</div>
<div className="skill-card-summary">
<h3 className="skill-card-title">{item.displayName}</h3>
<p className="skill-card-summary">
{item.summary ?? "No summary provided."}
</div>
<div className="skill-card-footer skill-card-footer-inline">
<div className="stat">
{item.ownerHandle ? `by ${item.ownerHandle}` : "community plugin"}
{item.latestVersion ? ` · v${item.latestVersion}` : ""}
</div>
{item.isOfficial ? <VerifiedBadge /> : null}
</p>
<div className="skill-card-footer" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="stat">
{item.ownerHandle ? `by ${item.ownerHandle}` : "community"}
</span>
{item.latestVersion ? (
<span className="stat">v{item.latestVersion}</span>
) : null}
</div>
</Link>
))}
</div>
{!search.q && (search.cursor || nextCursor) ? (
<div
className="card"
style={{ display: "flex", gap: 12, flexWrap: "wrap", justifyContent: "space-between", marginTop: 18 }}
style={{ display: "flex", gap: 12, justifyContent: "center", marginTop: 22 }}
>
<div className="section-subtitle" style={{ margin: 0 }}>
Browsing {items.length} plugin{items.length === 1 ? "" : "s"} per page.
</div>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
{search.cursor ? (
<button
className="btn"
type="button"
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
}),
});
}}
>
First page
</button>
) : null}
{nextCursor ? (
<button
className="btn btn-primary"
type="button"
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: nextCursor,
}),
});
}}
>
Next page
</button>
) : null}
</div>
{search.cursor ? (
<button
className="btn"
type="button"
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
}),
});
}}
>
First page
</button>
) : null}
{nextCursor ? (
<button
className="btn btn-primary"
type="button"
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: nextCursor,
}),
});
}}
>
Next page
</button>
) : null}
</div>
) : null}
</>
+30 -35
View File
@@ -78,49 +78,44 @@ export function SkillsResults({
})}
</div>
) : (
<div className="skills-list">
<div className="skills-table">
<div className="skills-table-header">
<span>Skill</span>
<span>Summary</span>
<span>Author</span>
<span className="skills-table-stats">Stats</span>
</div>
{sorted.map((entry) => {
const skill = entry.skill;
const clawdis = entry.latestVersion?.parsed?.clawdis;
const isPlugin = Boolean(clawdis?.nix?.plugin);
const platforms = getPlatformLabels(clawdis?.os, clawdis?.nix?.systems);
const ownerHandle = entry.owner?.handle ?? entry.ownerHandle ?? null;
const skillHref = buildSkillHref(skill, ownerHandle);
return (
<Link key={skill._id} className="skills-row" to={skillHref}>
<div className="skills-row-main">
<div className="skills-row-title">
<span>{skill.displayName}</span>
<span className="skills-row-slug">/{skill.slug}</span>
<Link key={skill._id} className="skills-table-row" to={skillHref}>
<span className="skills-table-name">
<span>
{skill.displayName}
{getSkillBadges(skill).map((badge) => (
<span key={badge} className="tag">
{badge}
</span>
<span key={badge} className="tag tag-compact">{badge}</span>
))}
{isPlugin ? (
<span className="tag tag-accent tag-compact">Plugin bundle (nix)</span>
) : null}
{platforms.map((label) => (
<span key={label} className="tag tag-compact">
{label}
</span>
))}
</div>
<div className="skills-row-summary">
{skill.summary ?? "No summary provided."}
</div>
<div className="skills-row-owner">
<UserBadge
user={entry.owner}
fallbackHandle={ownerHandle}
prefix="by"
link={false}
/>
</div>
</div>
<div className="skills-row-metrics">
</span>
{entry.latestVersion?.version ? (
<span className="skills-table-version">v{entry.latestVersion.version}</span>
) : null}
</span>
<span className="skills-table-summary">
{skill.summary ?? "No summary provided."}
</span>
<span className="skills-table-author">
<UserBadge
user={entry.owner}
fallbackHandle={ownerHandle}
prefix=""
link={false}
/>
</span>
<span className="skills-table-stats">
<SkillMetricsRow stats={skill.stats} />
</div>
</span>
</Link>
);
})}
+143 -2
View File
@@ -248,6 +248,30 @@ code {
.theme-toggle {
display: inline-flex;
gap: 2px;
}
.theme-toggle button {
all: unset;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
color: var(--ink-soft);
opacity: 0.5;
transition: all 0.2s ease;
}
.theme-toggle button:hover {
opacity: 0.8;
}
.theme-toggle button[data-state="on"] {
opacity: 1;
color: var(--ink);
}
.user-trigger {
@@ -288,6 +312,7 @@ code {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
@@ -1217,12 +1242,14 @@ code {
appearance: none;
border: 1px solid var(--border-ui);
border-radius: var(--radius-pill);
padding: 10px 14px;
background: rgba(255, 255, 255, 0.45);
padding: 10px 20px 10px 14px;
background: rgba(255, 255, 255, 0.45) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%236b5549' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E") no-repeat right 7px center;
background-size: 10px;
color: var(--ink);
font-weight: 650;
font-size: 0.9rem;
cursor: pointer;
width: fit-content;
}
[data-theme="dark"] .skills-sort {
@@ -1270,6 +1297,119 @@ code {
background: var(--surface);
}
.skills-table {
border-radius: var(--radius-md);
border: 1px solid var(--line);
overflow: hidden;
background: var(--surface);
}
.skills-table-header {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(0, 2fr) 120px 140px;
gap: 12px;
padding: 10px 18px;
font-size: 0.78rem;
font-weight: 650;
color: var(--ink-soft);
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--line);
background: rgba(42, 31, 25, 0.03);
}
.skills-table-row {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(0, 2fr) 120px 140px;
gap: 12px;
padding: 12px 18px;
text-decoration: none;
color: inherit;
border-bottom: 1px solid var(--line);
transition: background 0.15s ease;
align-items: center;
}
.skills-table-row:last-child {
border-bottom: none;
}
.skills-table-row:hover {
background: rgba(255, 107, 74, 0.07);
}
[data-theme="dark"] .skills-table-row:hover {
background: rgba(232, 106, 71, 0.11);
}
[data-theme="dark"] .skills-table-header {
background: rgba(255, 255, 255, 0.03);
}
.skills-table-name {
font-family: var(--font-display);
font-size: 0.95rem;
font-weight: 600;
letter-spacing: -0.01em;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.skills-table-name > span:first-child {
display: flex;
align-items: baseline;
gap: 6px;
flex-wrap: wrap;
word-break: break-word;
}
.skills-table-summary {
color: var(--ink-soft);
font-size: 0.85rem;
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
min-width: 0;
}
.skills-table-author {
font-size: 0.85rem;
color: var(--ink-soft);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skills-table-stats {
display: flex;
gap: 10px;
font-size: 0.82rem;
color: var(--ink-soft);
white-space: nowrap;
}
.skills-table-version {
font-size: 0.85rem;
color: var(--ink-soft);
}
@media (max-width: 860px) {
.skills-table-header,
.skills-table-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr) 100px;
}
.skills-table-stats {
display: none;
}
.skills-table-header span:last-child {
display: none;
}
}
.skills-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -1386,6 +1526,7 @@ code {
.skill-card {
min-height: 176px;
box-shadow: 0 1px 3px rgba(42, 31, 25, 0.08);
}
.skill-card-tags {