Compare commits

...
Author SHA1 Message Date
Patrick Erichsen b3c42b661b Merge pull request #1882 from openclaw/pe/plugin-management-tools
Add plugin curation to management
2026-04-28 22:31:44 -07:00
Vincent Koc f72179f37d chore(ci): update package publish artifact action
Update package publish artifact upload to the Node 24-ready artifact action and align the stale skills default-sort test with current filter behavior.
2026-04-28 22:31:33 -07:00
Patrick Erichsen 1d79f78426 feat: add plugin curation to management 2026-04-28 22:30:43 -07:00
Patrick Erichsen 6209fe3fff Merge pull request #1880 from openclaw/pe/featured-plugin-curation
feat: add featured plugin curation
2026-04-28 22:19:21 -07:00
Patrick Erichsen a7d1701f5a feat: add featured plugin curation 2026-04-28 22:18:35 -07:00
Patrick Erichsen 52da4954f6 Merge pull request #1871 from openclaw/pe/skills-plugins-typeahead
[codex] Add skills/plugins search typeahead
2026-04-28 21:24:26 -07:00
Patrick Erichsen 0ee5958f7a merge: sync with origin main 2026-04-28 21:18:34 -07:00
Patrick Erichsen 5d01b99adb Merge pull request #1878 from openclaw/pe/clawhub-rescan-guidance
feat: add ClawHub rescan guidance workflow
2026-04-28 20:09:30 -07:00
Patrick Erichsen 5dc834c27e feat: add ClawHub rescan guidance workflow 2026-04-28 20:07:52 -07:00
Patrick Erichsen 82b9a69dad Merge pull request #1875 from openclaw/pe/settings-stars
fix: move stars link into settings
2026-04-28 19:50:45 -07:00
Vincent Koc 064804e2d3 fix: make package publish retries idempotent 2026-04-28 19:29:39 -07:00
Patrick Erichsen 6c0163f9f2 feat: add skills plugins search typeahead 2026-04-28 18:33:20 -07:00
Patrick Erichsen 04a862d2b2 fix: move stars link into settings 2026-04-28 18:32:09 -07:00
Patrick Erichsen a7fc4bbae2 Merge pull request #1874 from openclaw/pe/oxfmt-pr-check
ci: check oxfmt on pull requests
2026-04-28 18:22:56 -07:00
Patrick Erichsen 4701c555f3 ci: check oxfmt on pull requests 2026-04-28 18:16:32 -07:00
Patrick Erichsen c1f167721b Merge pull request #1873 from openclaw/pe/fix-skill-upload
fix: add skill upload button to header
2026-04-28 17:37:14 -07:00
Patrick Erichsen 1a94744484 Update $name.tsx 2026-04-28 17:37:02 -07:00
Patrick Erichsen 9a5cfeee85 Update SkillHeader.tsx 2026-04-28 17:29:14 -07:00
Patrick Erichsen e69b7d4501 fix: add skill upload button to header 2026-04-28 17:25:30 -07:00
Patrick Erichsen ecf09b868a Merge pull request #1872 from openclaw/pe/clawhub-cli-0.12.1
chore(release): prepare clawhub cli 0.12.1
2026-04-28 16:53:37 -07:00
Patrick Erichsen 4d16472f5b chore(release): prepare clawhub cli 0.12.0 2026-04-28 16:52:53 -07:00
39 changed files with 3119 additions and 1220 deletions
+20
View File
@@ -12,6 +12,8 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@e3914758a49697077f7bcd190d36582a61667aad
with:
@@ -22,6 +24,24 @@ jobs:
- name: Peer deps
run: bun run check:peers
- name: Format
if: github.event_name == 'pull_request'
run: |
mapfile -d '' changed_files < <(
git diff --name-only --diff-filter=ACMR -z \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}" \
-- \
'*.css' '*.js' '*.jsx' '*.json' '*.md' '*.mjs' '*.ts' '*.tsx' '*.yaml' '*.yml'
)
if (( ${#changed_files[@]} == 0 )); then
echo "No changed files supported by oxfmt."
exit 0
fi
bun run format:check -- "${changed_files[@]}"
- name: Lint
run: bun run lint
@@ -0,0 +1,38 @@
name: ClawHub Rescan Guidance
on:
issues:
types: [labeled]
workflow_dispatch:
inputs:
issue:
description: "Issue number to check"
required: true
type: string
permissions:
contents: read
issues: write
concurrency:
group: clawhub-rescan-guidance-${{ github.event.issue.number || github.event.inputs.issue }}
cancel-in-progress: false
jobs:
rescan-guidance:
runs-on: ubuntu-latest
if: "${{ github.event_name == 'workflow_dispatch' || github.event.label.name == 'r: rescan-guidance' }}"
env:
GH_TOKEN: ${{ github.token }}
CLAWHUB_RESCAN_GUIDANCE_APPLY: "1"
ISSUE_NUMBER: ${{ github.event.issue.number || github.event.inputs.issue }}
steps:
- uses: actions/checkout@v4
- name: Comment when rescan guidance label is present
run: |
node scripts/github/clawhub-rescan-auto-response.mjs \
--repo "$GITHUB_REPOSITORY" \
--issue "$ISSUE_NUMBER" \
--comment-for-labeled-issue \
--apply
+4 -1
View File
@@ -73,6 +73,9 @@ on:
description: Published release id when dry_run is false.
value: ${{ jobs.publish.outputs.release_id }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
publish:
runs-on: ubuntu-latest
@@ -334,7 +337,7 @@ jobs:
PY
- name: Upload publish JSON artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: clawhub-package-publish-json
path: ${{ runner.temp }}/package-publish.json
+9 -5
View File
@@ -15,6 +15,7 @@
- `bun run preview` — preview built app.
- `bunx convex dev` — Convex dev deployment + function watcher.
- `bunx convex codegen` — regenerate `convex/_generated`.
- `bun run format:check` — formatting check.
- `bun run lint` — Biome + oxlint (type-aware).
- `bun run test` — Vitest (unit tests).
- `bun run coverage` — coverage run; keep global >= 80%.
@@ -37,6 +38,7 @@
- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`…).
- Keep changes scoped; avoid repo-wide search/replace.
- Before commit/PR handoff, run `bun run format:check` and `bun run lint`; include commands run in the PR summary.
- PRs: include summary + test commands run. Add screenshots for UI changes.
- Before merging any PR, verify TypeScript cleanly with `bunx tsc -p packages/schema/tsconfig.json --noEmit` and `bunx tsc -p packages/clawhub/tsconfig.json --noEmit`; if Convex code changed, also run the repo typecheck path used by deploy so `bunx convex deploy` will not fail on `tsc`.
- GitHub comments: for multiline `gh` comments/close messages, use `--body-file`, `--input`, or stdin/heredoc with real newlines; never pass literal `\\n` in shell strings.
@@ -90,11 +92,13 @@
- **Before writing or reviewing Convex queries, check deployment health.** Run `bunx convex insights` to check for OCC conflicts, `bytesReadLimit`, and `documentsReadLimit` errors. Run `bunx convex logs --failure` to see individual error messages and stack traces. This helps identify which functions are causing bandwidth issues so you can prioritize fixes.
<!-- convex-ai-start -->
This project uses [Convex](https://convex.dev) as its backend.
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
<!-- convex-ai-end -->
## Stat Field Migration Rules
@@ -102,11 +106,11 @@ Convex agent skills for common tasks can be installed by running `npx convex ai-
The `skills` table maintains two parallel sets of stat fields as part of an in-progress field migration:
| Legacy (nested, `@deprecated`) | Top-level (source of truth, indexable) |
|---|---|
| `stats.downloads` | `statsDownloads` |
| `stats.stars` | `statsStars` |
| `stats.installsCurrent` | `statsInstallsCurrent` |
| `stats.installsAllTime` | `statsInstallsAllTime` |
| ------------------------------ | -------------------------------------- |
| `stats.downloads` | `statsDownloads` |
| `stats.stars` | `statsStars` |
| `stats.installsCurrent` | `statsInstallsCurrent` |
| `stats.installsAllTime` | `statsInstallsAllTime` |
**Rules:**
+1 -1
View File
@@ -94,7 +94,7 @@
},
"packages/clawhub": {
"name": "clawhub",
"version": "0.11.0",
"version": "0.12.0",
"bin": {
"clawdhub": "bin/clawdhub.js",
"clawhub": "bin/clawdhub.js",
+393 -15
View File
@@ -20,6 +20,19 @@ type SeedSkillSpec = {
rawSkillMd: string;
};
type SeedPluginSpec = {
name: string;
displayName: string;
summary: string;
version: string;
runtimeId: string;
sourceRepo: string;
isOfficial: boolean;
capabilityTags: string[];
stats: { downloads: number; installs: number; stars: number; versions: number };
readme: string;
};
type SeedActionArgs = {
reset?: boolean;
};
@@ -56,6 +69,134 @@ This seeded plugin is public and intentionally has completed scan results so loc
preview plugin scanner detail pages without owner-only visibility.
`;
const FEATURED_PLUGIN_SEEDS: SeedPluginSpec[] = [
{
name: "@apify/apify-openclaw-plugin",
displayName: "Apify",
summary:
"Scrape websites through Apify actors and make structured web data available to agents.",
version: "1.0.0",
runtimeId: "apify",
sourceRepo: "apify/apify-openclaw-plugin",
isOfficial: false,
capabilityTags: ["web", "scraping", "automation"],
stats: { downloads: 1200, installs: 320, stars: 45, versions: 1 },
readme: "# Apify\n\nScrape websites through Apify actors from OpenClaw.",
},
{
name: "openclaw-codex-app-server",
displayName: "Codex App Server Bridge",
summary: "Bind OpenClaw chats to Codex App Server conversations and control threads from chat.",
version: "1.0.0",
runtimeId: "codex-app-server",
sourceRepo: "pwrdrvr/openclaw-codex-app-server",
isOfficial: false,
capabilityTags: ["codex", "chat", "bridge"],
stats: { downloads: 980, installs: 280, stars: 37, versions: 1 },
readme: "# Codex App Server Bridge\n\nBridge OpenClaw chat sessions to Codex App Server.",
},
{
name: "@largezhou/ddingtalk",
displayName: "DingTalk",
summary: "Connect OpenClaw to DingTalk enterprise robots with text, image, and file messages.",
version: "1.0.0",
runtimeId: "dingtalk",
sourceRepo: "largezhou/openclaw-dingtalk",
isOfficial: false,
capabilityTags: ["channel", "dingtalk", "enterprise"],
stats: { downloads: 930, installs: 250, stars: 32, versions: 1 },
readme: "# DingTalk\n\nDingTalk enterprise robot plugin for OpenClaw.",
},
{
name: "kudosity-openclaw-sms",
displayName: "Kudosity SMS",
summary: "Send and receive SMS through Kudosity as an OpenClaw plugin.",
version: "1.0.0",
runtimeId: "kudosity-sms",
sourceRepo: "kudosity/openclaw-sms",
isOfficial: false,
capabilityTags: ["channel", "sms", "kudosity"],
stats: { downloads: 860, installs: 210, stars: 29, versions: 1 },
readme: "# Kudosity SMS\n\nKudosity SMS channel plugin for OpenClaw.",
},
{
name: "@martian-engineering/lossless-claw",
displayName: "Lossless Claw",
summary:
"Preserve conversation context with DAG-based summarization and incremental compaction.",
version: "1.0.0",
runtimeId: "lossless-claw",
sourceRepo: "Martian-Engineering/lossless-claw",
isOfficial: false,
capabilityTags: ["memory", "context", "summarization"],
stats: { downloads: 820, installs: 190, stars: 28, versions: 1 },
readme: "# Lossless Claw\n\nLossless context management plugin for OpenClaw.",
},
{
name: "@opik/opik-openclaw",
displayName: "Opik",
summary: "Export OpenClaw traces to Opik for monitoring, costs, token usage, and debugging.",
version: "1.0.0",
runtimeId: "opik",
sourceRepo: "comet-ml/opik-openclaw",
isOfficial: true,
capabilityTags: ["observability", "tracing", "monitoring"],
stats: { downloads: 760, installs: 180, stars: 25, versions: 1 },
readme: "# Opik\n\nTrace OpenClaw agents with Opik.",
},
{
name: "@prometheusavatar/openclaw-plugin",
displayName: "Prometheus Avatar",
summary: "Give OpenClaw agents a Live2D avatar with lip-sync, expressions, and speech.",
version: "1.0.0",
runtimeId: "prometheus-avatar",
sourceRepo: "myths-labs/prometheus-avatar",
isOfficial: false,
capabilityTags: ["avatar", "tts", "live2d"],
stats: { downloads: 690, installs: 150, stars: 22, versions: 1 },
readme: "# Prometheus Avatar\n\nLive2D avatar plugin for OpenClaw.",
},
{
name: "@tencent-connect/openclaw-qqbot",
displayName: "QQbot",
summary:
"Connect OpenClaw to QQ private chats, group mentions, channel messages, and rich media.",
version: "1.0.0",
runtimeId: "qqbot",
sourceRepo: "tencent-connect/openclaw-qqbot",
isOfficial: true,
capabilityTags: ["channel", "qq", "messaging"],
stats: { downloads: 640, installs: 140, stars: 20, versions: 1 },
readme: "# QQbot\n\nQQ Bot plugin for OpenClaw.",
},
{
name: "@wecom/wecom-openclaw-plugin",
displayName: "wecom",
summary:
"Use WeCom Bot WebSocket connections for direct messages, group chats, and proactive messaging.",
version: "1.0.0",
runtimeId: "wecom",
sourceRepo: "WecomTeam/wecom-openclaw-plugin",
isOfficial: true,
capabilityTags: ["channel", "wecom", "enterprise"],
stats: { downloads: 610, installs: 130, stars: 18, versions: 1 },
readme: "# wecom\n\nWeCom channel plugin for OpenClaw.",
},
{
name: "openclaw-plugin-yuanbao",
displayName: "Yuanbao",
summary:
"Connect OpenClaw to Yuanbao with direct messages, group chats, media, and slash commands.",
version: "1.0.0",
runtimeId: "yuanbao",
sourceRepo: "yb-claw/openclaw-plugin-yuanbao",
isOfficial: false,
capabilityTags: ["channel", "yuanbao", "messaging"],
stats: { downloads: 580, installs: 125, stars: 17, versions: 1 },
readme: "# Yuanbao\n\nYuanbao channel plugin for OpenClaw.",
},
];
type RoleHelpFixtureUser = {
handle: string;
displayName: string;
@@ -382,12 +523,13 @@ async function seedNixSkillsHandler(
results.push({ slug: spec.slug, ...result });
}
const [flaggedSkillStorageId, flaggedPluginStorageId, scannedPluginStorageId] =
await Promise.all([
ctx.storage.store(new Blob([FLAGGED_SKILL_MD], { type: "text/markdown" })),
ctx.storage.store(new Blob([FLAGGED_PLUGIN_README], { type: "text/markdown" })),
ctx.storage.store(new Blob([SCANNED_PLUGIN_README], { type: "text/markdown" })),
]);
const [flaggedSkillStorageId, flaggedPluginStorageId, scannedPluginStorageId] = await Promise.all(
[
ctx.storage.store(new Blob([FLAGGED_SKILL_MD], { type: "text/markdown" })),
ctx.storage.store(new Blob([FLAGGED_PLUGIN_README], { type: "text/markdown" })),
ctx.storage.store(new Blob([SCANNED_PLUGIN_README], { type: "text/markdown" })),
],
);
const fixtureResult: SeedMutationResult = await ctx.runMutation(
internal.devSeed.seedRescanUxFixturesMutation,
{
@@ -402,6 +544,32 @@ async function seedNixSkillsHandler(
);
results.push({ slug: FLAGGED_SKILL_SLUG, ...fixtureResult });
const featuredPluginStorageIds = await Promise.all(
FEATURED_PLUGIN_SEEDS.map(async (spec) =>
ctx.storage.store(new Blob([spec.readme], { type: "text/markdown" })),
),
);
const featuredResult: SeedMutationResult = await ctx.runMutation(
internal.devSeed.seedFeaturedPluginPackagesMutation,
{
reset: args.reset,
packages: FEATURED_PLUGIN_SEEDS.map((spec, index) => ({
name: spec.name,
displayName: spec.displayName,
summary: spec.summary,
version: spec.version,
runtimeId: spec.runtimeId,
sourceRepo: spec.sourceRepo,
isOfficial: spec.isOfficial,
capabilityTags: spec.capabilityTags,
stats: spec.stats,
storageId: featuredPluginStorageIds[index],
readmeSize: spec.readme.length,
})),
},
);
results.push({ slug: "featured-plugins", ...featuredResult });
return { ok: true, results };
}
@@ -562,6 +730,59 @@ async function findScannedPluginFixture(ctx: MutationCtx) {
return await findSeedPluginFixtureByName(ctx, SCANNED_PLUGIN_NAME);
}
async function ensureHighlightedSkillBadge(
ctx: MutationCtx,
skillId: Id<"skills">,
userId: Id<"users">,
at: number,
) {
const existing = await ctx.db
.query("skillBadges")
.withIndex("by_skill_kind", (q) => q.eq("skillId", skillId).eq("kind", "highlighted"))
.unique();
if (existing) {
await ctx.db.patch(existing._id, { byUserId: userId, at });
} else {
await ctx.db.insert("skillBadges", {
skillId,
kind: "highlighted",
byUserId: userId,
at,
});
}
const skill = await ctx.db.get(skillId);
if (skill) {
await ctx.db.patch(skillId, {
badges: {
...(skill.badges as Record<string, unknown> | undefined),
highlighted: { byUserId: userId, at },
},
});
}
}
async function ensureHighlightedPackageBadge(
ctx: MutationCtx,
packageId: Id<"packages">,
userId: Id<"users">,
at: number,
) {
const existing = await ctx.db
.query("packageBadges")
.withIndex("by_package_kind", (q) => q.eq("packageId", packageId).eq("kind", "highlighted"))
.unique();
if (existing) {
await ctx.db.patch(existing._id, { byUserId: userId, at });
} else {
await ctx.db.insert("packageBadges", {
packageId,
kind: "highlighted",
byUserId: userId,
at,
});
}
}
function staticMaliciousScan(now: number) {
return {
status: "malicious" as const,
@@ -1083,6 +1304,166 @@ export const seedRescanUxFixturesMutation = internalMutation({
handler: seedRescanUxFixturesHandler,
});
export const seedFeaturedPluginPackagesMutation = internalMutation({
args: {
reset: v.optional(v.boolean()),
packages: v.array(
v.object({
name: v.string(),
displayName: v.string(),
summary: v.string(),
version: v.string(),
runtimeId: v.string(),
sourceRepo: v.string(),
isOfficial: v.boolean(),
capabilityTags: v.array(v.string()),
stats: v.object({
downloads: v.number(),
installs: v.number(),
stars: v.number(),
versions: v.number(),
}),
storageId: v.id("_storage"),
readmeSize: v.number(),
}),
),
},
handler: async (ctx, args) => {
const now = Date.now();
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
const seeded: string[] = [];
const skipped: string[] = [];
for (const spec of args.packages) {
const existing = await findSeedPluginFixtureByName(ctx, spec.name);
if (existing && !args.reset) {
await ensureHighlightedPackageBadge(ctx, existing._id, userId, now);
skipped.push(spec.name);
continue;
}
if (existing && args.reset) {
await deleteSeedPluginFixtureByName(ctx, spec.name);
}
const compatibility = { pluginApiRange: ">=0.1.0" };
const capabilities = {
executesCode: true,
runtimeId: spec.runtimeId,
pluginKind: "runtime" as const,
capabilityTags: spec.capabilityTags,
};
const verification = {
tier: "source-linked" as const,
scope: "artifact-only" as const,
summary: "Local dev featured plugin fixture linked to source metadata.",
sourceRepo: spec.sourceRepo,
scanStatus: "clean" as const,
};
const normalizedName = normalizePackageName(spec.name);
const packageId = await ctx.db.insert("packages", {
name: spec.name,
normalizedName,
displayName: spec.displayName,
summary: spec.summary,
ownerUserId: userId,
ownerPublisherId: publisherId,
family: "code-plugin",
channel: "community",
isOfficial: spec.isOfficial,
runtimeId: spec.runtimeId,
sourceRepo: spec.sourceRepo,
latestReleaseId: undefined,
latestVersionSummary: undefined,
tags: {},
capabilityTags: spec.capabilityTags,
executesCode: true,
compatibility,
capabilities,
verification,
scanStatus: "clean",
stats: { ...spec.stats, versions: 0 },
softDeletedAt: undefined,
createdAt: now,
updatedAt: now,
});
const releaseId = await ctx.db.insert("packageReleases", {
packageId,
version: spec.version,
changelog: "Seeded local featured plugin release.",
summary: spec.summary,
distTags: ["latest"],
files: [
{
path: "README.md",
size: spec.readmeSize,
storageId: spec.storageId,
sha256: `seeded-featured-plugin-${normalizedName}`,
contentType: "text/markdown",
},
],
integritySha256: `seeded-featured-plugin-integrity-${normalizedName}`,
extractedPackageJson: {
name: spec.name,
version: spec.version,
description: spec.summary,
},
compatibility,
capabilities,
verification,
sha256hash: `seeded-featured-plugin-hash-${normalizedName}`,
vtAnalysis: {
status: "clean",
verdict: "clean",
analysis: "Local featured plugin fixture scanned clean.",
source: "local-dev-seed",
checkedAt: now,
},
llmAnalysis: {
status: "clean",
verdict: "clean",
confidence: "high",
summary: "Local featured plugin fixture is safe sample content.",
model: "local-dev-seed",
checkedAt: now,
},
staticScan: {
status: "clean",
reasonCodes: [],
findings: [],
summary: "Local featured plugin fixture static scan clean.",
engineVersion: "local-dev-fixture",
checkedAt: now,
},
source: { kind: "github", repo: spec.sourceRepo, path: "." },
createdBy: userId,
publishActor: { kind: "user", userId },
createdAt: now,
softDeletedAt: undefined,
});
await ctx.db.patch(packageId, {
latestReleaseId: releaseId,
latestVersionSummary: {
version: spec.version,
createdAt: now,
changelog: "Seeded local featured plugin release.",
compatibility,
capabilities,
verification,
},
tags: { latest: releaseId },
stats: { ...spec.stats, versions: 1 },
updatedAt: now,
});
await ensureHighlightedPackageBadge(ctx, packageId, userId, now);
seeded.push(spec.name);
}
return { ok: true, seeded, skipped };
},
});
export const seedCliRoleHelpFixtures = rawInternalMutation({
args: {},
handler: async (ctx) => {
@@ -1135,11 +1516,7 @@ async function upsertRoleHelpFixtureUser(ctx: MutationCtx, user: RoleHelpFixture
return created;
}
async function replaceRoleHelpFixtureToken(
ctx: MutationCtx,
userId: Id<"users">,
now: number,
) {
async function replaceRoleHelpFixtureToken(ctx: MutationCtx, userId: Id<"users">, now: number) {
const existingTokens = await ctx.db
.query("apiTokens")
.withIndex("by_user", (q) => q.eq("userId", userId))
@@ -1177,12 +1554,15 @@ export const seedSkillMutation = internalMutation({
version: v.string(),
},
handler: async (ctx, args) => {
const now = Date.now();
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
const existing = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.unique();
if (existing && !args.reset) {
await ensureHighlightedSkillBadge(ctx, existing._id, userId, now);
return { ok: true, skipped: true, skillId: existing._id };
}
@@ -1204,9 +1584,6 @@ export const seedSkillMutation = internalMutation({
await ctx.db.delete(existing._id);
}
const now = Date.now();
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
const skillId = await ctx.db.insert("skills", {
slug: args.slug,
displayName: args.displayName,
@@ -1216,7 +1593,7 @@ export const seedSkillMutation = internalMutation({
latestVersionId: undefined,
tags: {},
softDeletedAt: undefined,
badges: { redactionApproved: undefined },
badges: { highlighted: { byUserId: userId, at: now }, redactionApproved: undefined },
statsDownloads: 0,
statsStars: 0,
statsInstallsCurrent: 0,
@@ -1232,6 +1609,7 @@ export const seedSkillMutation = internalMutation({
createdAt: now,
updatedAt: now,
});
await ensureHighlightedSkillBadge(ctx, skillId, userId, now);
const versionId = await ctx.db.insert("skillVersions", {
skillId,
version: args.version,
+24 -1
View File
@@ -7,8 +7,8 @@ import {
import { api, internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { getOptionalApiTokenUserId } from "../lib/apiTokenAuth";
import { getOptionalActiveAuthUserIdFromAction } from "../lib/access";
import { getOptionalApiTokenUserId } from "../lib/apiTokenAuth";
import {
fetchGitHubRepositoryIdentity,
verifyGitHubActionsTrustedPublishJwt,
@@ -104,6 +104,7 @@ type PackageListQueryArgs = {
family?: "skill" | "code-plugin" | "bundle-plugin";
channel?: "official" | "community" | "private";
isOfficial?: boolean;
highlightedOnly?: boolean;
executesCode?: boolean;
capabilityTag?: string;
viewerUserId?: Id<"users">;
@@ -458,6 +459,7 @@ async function searchPackageCatalogByListing(
family?: "skill" | "code-plugin" | "bundle-plugin";
channel?: "official" | "community" | "private";
isOfficial?: boolean;
highlightedOnly?: boolean;
executesCode?: boolean;
capabilityTag?: string;
viewerUserId?: Id<"users">;
@@ -482,6 +484,7 @@ async function searchPackageCatalogByListing(
family: args.family,
channel: args.channel,
isOfficial: args.isOfficial,
highlightedOnly: args.highlightedOnly,
executesCode: args.executesCode,
capabilityTag: args.capabilityTag,
viewerUserId: args.viewerUserId,
@@ -650,6 +653,11 @@ async function listPackages(
const channelRaw = url.searchParams.get("channel")?.trim();
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
const isOfficialRaw = url.searchParams.get("isOfficial");
const highlightedOnly =
url.searchParams.get("featured") === "true" ||
url.searchParams.get("featured") === "1" ||
url.searchParams.get("highlightedOnly") === "true" ||
url.searchParams.get("highlightedOnly") === "1";
const executesCodeRaw = url.searchParams.get("executesCode");
const effectiveFamily =
family ??
@@ -674,6 +682,7 @@ async function listPackages(
}>(ctx, apiRefs.skills.listPackageCatalogPage, {
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
paginationOpts: { cursor, numItems: limit },
@@ -701,6 +710,7 @@ async function listPackages(
}>(ctx, internalRefs.packages.listPageForViewerInternal, {
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
@@ -720,6 +730,7 @@ async function listPackages(
}>(ctx, apiRefs.skills.listPackageCatalogPage, {
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
paginationOpts: { cursor: pageCursor, numItems },
@@ -783,6 +794,7 @@ async function listPackages(
family: pluginFamily,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
@@ -850,6 +862,7 @@ async function listPackages(
family: effectiveFamily,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
@@ -1279,6 +1292,11 @@ async function searchPackages(
const familyRaw = url.searchParams.get("family");
const channelRaw = url.searchParams.get("channel");
const isOfficialRaw = url.searchParams.get("isOfficial");
const highlightedOnly =
url.searchParams.get("featured") === "true" ||
url.searchParams.get("featured") === "1" ||
url.searchParams.get("highlightedOnly") === "true" ||
url.searchParams.get("highlightedOnly") === "1";
const executesCodeRaw = url.searchParams.get("executesCode");
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
const family =
@@ -1305,6 +1323,7 @@ async function searchPackages(
limit,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
},
@@ -1319,6 +1338,7 @@ async function searchPackages(
family: pluginFamily,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
@@ -1343,6 +1363,7 @@ async function searchPackages(
family,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
@@ -1355,6 +1376,7 @@ async function searchPackages(
limit,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
@@ -1364,6 +1386,7 @@ async function searchPackages(
limit,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
}),
+87 -2
View File
@@ -123,6 +123,7 @@ const insertReleaseInternalHandler = (
capabilities?: unknown;
verification?: unknown;
staticScan?: unknown;
allowExistingRelease?: boolean;
extractedPackageJson?: unknown;
extractedPluginManifest?: unknown;
normalizedBundleManifest?: unknown;
@@ -577,16 +578,40 @@ function makeInsertReleaseCtx(
}
if (table === "packageReleases") {
return {
withIndex: vi.fn((indexName: string) => {
withIndex: vi.fn(
(
indexName: string,
buildQuery?: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
if (indexName === "by_package") {
return {
collect: vi.fn().mockResolvedValue(priorReleases),
};
}
if (indexName === "by_package_version") {
const filters = new Map<string, unknown>();
const query = {
eq(field: string, value: unknown) {
filters.set(field, value);
return query;
},
};
buildQuery?.(query);
return {
unique: vi.fn().mockResolvedValue(
priorReleases.find(
(release) =>
release.packageId === filters.get("packageId") &&
release.version === filters.get("version"),
) ?? null,
),
};
}
return {
unique: vi.fn().mockResolvedValue(null),
};
}),
},
),
};
}
throw new Error(`Unexpected table ${table}`);
@@ -2011,6 +2036,66 @@ describe("packages public queries", () => {
});
});
it("rejects duplicate package versions by default", async () => {
const ctx = makeInsertReleaseCtx(makePackageDoc(), [
makeReleaseDoc({
_id: "packageReleases:existing",
version: "1.0.0",
integritySha256: "abc123",
}),
]);
await expect(
insertReleaseInternalHandler(ctx, {
actorUserId: "users:owner",
ownerUserId: "users:owner",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
version: "1.0.0",
changelog: "retry",
tags: ["latest"],
summary: "demo",
files: [],
integritySha256: "abc123",
}),
).rejects.toThrow("Version 1.0.0 already exists");
});
it("treats matching workflow duplicate package releases as idempotent", async () => {
const ctx = makeInsertReleaseCtx(makePackageDoc(), [
makeReleaseDoc({
_id: "packageReleases:existing",
version: "1.0.0",
integritySha256: "abc123",
}),
]);
await expect(
insertReleaseInternalHandler(ctx, {
actorUserId: "users:owner",
ownerUserId: "users:owner",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
version: "1.0.0",
changelog: "retry",
tags: ["latest"],
summary: "demo",
files: [],
integritySha256: "abc123",
allowExistingRelease: true,
}),
).resolves.toMatchObject({
ok: true,
packageId: "packages:demo",
releaseId: "packageReleases:existing",
});
expect(ctx.insert).not.toHaveBeenCalled();
expect(ctx.patch).not.toHaveBeenCalled();
});
it("adds a latest tag when an untagged promoted release becomes the package latest", async () => {
const ctx = makeInsertReleaseCtx(
makePackageDoc({
+201 -1
View File
@@ -167,6 +167,8 @@ type PublicPackageListItem = {
executesCode: boolean;
verificationTier: PackageVerificationTier | null;
};
type PackageBadgeKind = Doc<"packageBadges">["kind"];
type PackageDigestLike = Pick<
Doc<"packageSearchDigest">,
| "packageId"
@@ -409,6 +411,36 @@ function digestMatchesSearchFilters(
return digestMatchesFilters(digest, args);
}
async function upsertPackageBadge(
ctx: MutationCtx,
packageId: Id<"packages">,
kind: PackageBadgeKind,
userId: Id<"users">,
at: number,
) {
const existing = await ctx.db
.query("packageBadges")
.withIndex("by_package_kind", (q) => q.eq("packageId", packageId).eq("kind", kind))
.unique();
if (existing) {
await ctx.db.patch(existing._id, { byUserId: userId, at });
return;
}
await ctx.db.insert("packageBadges", { packageId, kind, byUserId: userId, at });
}
async function removePackageBadge(
ctx: MutationCtx,
packageId: Id<"packages">,
kind: PackageBadgeKind,
) {
const existing = await ctx.db
.query("packageBadges")
.withIndex("by_package_kind", (q) => q.eq("packageId", packageId).eq("kind", kind))
.unique();
if (existing) await ctx.db.delete(existing._id);
}
function toPublicPackageListItem(digest: PackageDigestLike): PublicPackageListItem {
return {
name: digest.name,
@@ -935,6 +967,62 @@ function buildPackageCapabilityDigestQuery(
);
}
async function fetchHighlightedPackageDigests(
ctx: DbReaderCtx,
args: {
family?: PackageFamily;
channel?: PackageChannel;
isOfficial?: boolean;
executesCode?: boolean;
capabilityTag?: string;
viewerUserId?: Id<"users">;
},
) {
const viewerUserId = args.viewerUserId;
const membershipCache = new Map<string, Promise<boolean>>();
const badges = await ctx.db
.query("packageBadges")
.withIndex("by_kind_at", (q) => q.eq("kind", "highlighted"))
.order("desc")
.take(MAX_PUBLIC_LIST_PAGE_SIZE);
const digests: PackageDigestLike[] = [];
for (const badge of badges) {
const digest = await ctx.db
.query("packageSearchDigest")
.withIndex("by_package", (q) => q.eq("packageId", badge.packageId))
.unique();
if (!digest || digest.softDeletedAt) continue;
if (!(await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache))) continue;
if (!digestMatchesSearchFilters(digest, args)) continue;
digests.push(digest);
}
return digests;
}
async function fetchHighlightedPackagePage(
ctx: DbReaderCtx,
args: {
family?: PackageFamily;
channel?: PackageChannel;
isOfficial?: boolean;
executesCode?: boolean;
capabilityTag?: string;
viewerUserId?: Id<"users">;
numItems: number;
},
) {
const digests = await fetchHighlightedPackageDigests(ctx, args);
return digests
.sort(
(a, b) =>
Number(b.isOfficial) - Number(a.isOfficial) ||
b.updatedAt - a.updatedAt ||
a.name.localeCompare(b.name),
)
.slice(0, args.numItems)
.map(toPublicPackageListItem);
}
async function getPackageByNormalizedName(ctx: DbReaderCtx, normalizedName: string) {
return (await ctx.db
.query("packages")
@@ -1011,6 +1099,41 @@ export const getByName = query({
},
});
export const getByNameForStaff = query({
args: { name: v.string() },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx);
assertModerator(user);
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") return null;
const highlighted = await ctx.db
.query("packageBadges")
.withIndex("by_package_kind", (q) => q.eq("packageId", pkg._id).eq("kind", "highlighted"))
.unique();
const latestRelease = pkg.latestReleaseId ? await ctx.db.get(pkg.latestReleaseId) : null;
const owner = toPublicPublisher(
await getOwnerPublisher(ctx, {
ownerPublisherId: pkg.ownerPublisherId,
ownerUserId: pkg.ownerUserId,
}),
);
return {
package: pkg,
latestRelease: latestRelease && !latestRelease.softDeletedAt ? latestRelease : null,
owner,
highlighted: highlighted
? {
byUserId: highlighted.byUserId,
at: highlighted.at,
}
: null,
};
},
});
export const getByNameForViewerInternal = internalQuery({
args: {
name: v.string(),
@@ -1158,6 +1281,7 @@ export const listPublicPage = query({
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
),
isOfficial: v.optional(v.boolean()),
highlightedOnly: v.optional(v.boolean()),
executesCode: v.optional(v.boolean()),
capabilityTag: v.optional(v.string()),
paginationOpts: paginationOptsValidator,
@@ -1176,6 +1300,7 @@ export const listPageForViewerInternal = internalQuery({
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
),
isOfficial: v.optional(v.boolean()),
highlightedOnly: v.optional(v.boolean()),
executesCode: v.optional(v.boolean()),
capabilityTag: v.optional(v.string()),
viewerUserId: v.optional(v.id("users")),
@@ -1192,6 +1317,7 @@ async function listPackagePageImpl(
family?: PackageFamily;
channel?: PackageChannel;
isOfficial?: boolean;
highlightedOnly?: boolean;
executesCode?: boolean;
capabilityTag?: string;
viewerUserId?: Id<"users">;
@@ -1206,6 +1332,15 @@ async function listPackagePageImpl(
const canViewPackage = async (digest: PackageDigestLike) =>
await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache);
const targetCount = args.paginationOpts.numItems;
if (args.highlightedOnly) {
const page = await fetchHighlightedPackagePage(ctx, {
...args,
numItems: targetCount,
});
return { page, isDone: true, continueCursor: "" };
}
const collected: PublicPackageListItem[] = [];
const decodedCursor = decodePublicPageCursor(args.paginationOpts.cursor);
if (decodedCursor.done && decodedCursor.offset === 0) {
@@ -1300,6 +1435,7 @@ export const searchPublic = query({
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
),
isOfficial: v.optional(v.boolean()),
highlightedOnly: v.optional(v.boolean()),
executesCode: v.optional(v.boolean()),
capabilityTag: v.optional(v.string()),
},
@@ -1319,6 +1455,7 @@ export const searchForViewerInternal = internalQuery({
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
),
isOfficial: v.optional(v.boolean()),
highlightedOnly: v.optional(v.boolean()),
executesCode: v.optional(v.boolean()),
capabilityTag: v.optional(v.string()),
viewerUserId: v.optional(v.id("users")),
@@ -1336,6 +1473,7 @@ async function searchPackagesImpl(
family?: PackageFamily;
channel?: PackageChannel;
isOfficial?: boolean;
highlightedOnly?: boolean;
executesCode?: boolean;
capabilityTag?: string;
viewerUserId?: Id<"users">;
@@ -1349,6 +1487,21 @@ async function searchPackagesImpl(
const membershipCache = new Map<string, Promise<boolean>>();
const canViewPackage = async (digest: PackageDigestLike) =>
await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache);
if (args.highlightedOnly) {
const digests = await fetchHighlightedPackageDigests(ctx, args);
return digests
.map((digest) => ({ score: packageSearchScore(digest, queryText), package: digest }))
.filter((entry) => entry.score > 0)
.sort(
(a, b) =>
b.score - a.score ||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
b.package.updatedAt - a.package.updatedAt,
)
.slice(0, targetCount)
.map((entry) => ({ score: entry.score, package: toPublicPackageListItem(entry.package) }));
}
const builder = args.capabilityTag
? buildPackageCapabilityDigestQuery(ctx, {
capabilityTag: args.capabilityTag,
@@ -2005,6 +2158,9 @@ async function publishPackageImpl(
staticScan,
files,
integritySha256,
allowExistingRelease:
auth.kind === "github-actions" ||
(auth.kind === "user" && manualOverrideReason?.startsWith("GitHub Actions ")),
extractedPackageJson: packageJson,
extractedPluginManifest:
family === "code-plugin" ? maybeParseJson(pluginManifestEntry?.text) : undefined,
@@ -2164,6 +2320,7 @@ export const insertReleaseInternal = internalMutation({
capabilities: v.optional(v.any()),
verification: v.optional(v.any()),
staticScan: v.optional(v.any()),
allowExistingRelease: v.optional(v.boolean()),
files: v.array(
v.object({
path: v.string(),
@@ -2285,7 +2442,20 @@ export const insertReleaseInternal = internalMutation({
q.eq("packageId", existing._id).eq("version", args.version),
)
.unique();
if (releaseExists) throw new ConvexError(`Version ${nextVersionLabel} already exists`);
if (releaseExists) {
if (
args.allowExistingRelease &&
!releaseExists.softDeletedAt &&
releaseExists.integritySha256 === args.integritySha256
) {
return {
ok: true as const,
packageId: existing._id,
releaseId: releaseExists._id,
};
}
throw new ConvexError(`Version ${nextVersionLabel} already exists`);
}
}
const priorReleases = existing
? await ctx.db
@@ -2780,6 +2950,36 @@ export const requestRescan = mutation({
},
});
export const setBatch = mutation({
args: { packageId: v.id("packages"), batch: v.optional(v.string()) },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx);
assertModerator(user);
const pkg = await ctx.db.get(args.packageId);
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
throw new ConvexError("Plugin not found");
}
const nextBatch = args.batch?.trim() || undefined;
const nextHighlighted = nextBatch === "highlighted";
const now = Date.now();
if (nextHighlighted) {
await upsertPackageBadge(ctx, pkg._id, "highlighted", user._id, now);
} else {
await removePackageBadge(ctx, pkg._id, "highlighted");
}
await ctx.db.insert("auditLogs", {
actorUserId: user._id,
action: "package.badge.highlighted",
targetType: "package",
targetId: pkg._id,
metadata: { highlighted: nextHighlighted },
createdAt: now,
});
},
});
export const requestRescanForApiTokenInternal = internalMutation({
args: {
actorUserId: v.id("users"),
+12 -6
View File
@@ -547,6 +547,16 @@ const skillBadges = defineTable({
.index("by_skill_kind", ["skillId", "kind"])
.index("by_kind_at", ["kind", "at"]);
const packageBadges = defineTable({
packageId: v.id("packages"),
kind: v.union(v.literal("highlighted")),
byUserId: v.id("users"),
at: v.number(),
})
.index("by_package", ["packageId"])
.index("by_package_kind", ["packageId", "kind"])
.index("by_kind_at", ["kind", "at"]);
const soulVersionFingerprints = defineTable({
soulId: v.id("souls"),
versionId: v.id("soulVersions"),
@@ -1219,12 +1229,7 @@ const rescanRequests = defineTable({
.index("by_skill_version", ["targetKind", "skillVersionId", "createdAt"])
.index("by_skill_version_status", ["targetKind", "skillVersionId", "status", "createdAt"])
.index("by_package_release", ["targetKind", "packageReleaseId", "createdAt"])
.index("by_package_release_status", [
"targetKind",
"packageReleaseId",
"status",
"createdAt",
])
.index("by_package_release_status", ["targetKind", "packageReleaseId", "status", "createdAt"])
.index("by_requester", ["requestedByUserId", "createdAt"]);
const apiTokens = defineTable({
@@ -1362,6 +1367,7 @@ export default defineSchema({
packageReleases,
packageTrustedPublishers,
packagePublishTokens,
packageBadges,
packageSearchDigest,
packageCapabilitySearchDigest,
souls,
+4
View File
@@ -3055,6 +3055,7 @@ function skillCatalogMatchesFilters(
args: {
channel?: "official" | "community" | "private";
isOfficial?: boolean;
highlightedOnly?: boolean;
executesCode?: boolean;
capabilityTag?: string;
},
@@ -3065,6 +3066,7 @@ function skillCatalogMatchesFilters(
const isOfficial = isSkillCatalogOfficial(digest);
const channel = getSkillCatalogChannel(digest);
if (typeof args.isOfficial === "boolean" && isOfficial !== args.isOfficial) return false;
if (args.highlightedOnly && !isSkillHighlighted(digest)) return false;
if (args.channel && channel !== args.channel) return false;
if (args.capabilityTag && !(digest.capabilityTags ?? []).includes(args.capabilityTag))
return false;
@@ -3120,6 +3122,7 @@ export const listPackageCatalogPage = query({
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
),
isOfficial: v.optional(v.boolean()),
highlightedOnly: v.optional(v.boolean()),
executesCode: v.optional(v.boolean()),
capabilityTag: v.optional(v.string()),
paginationOpts: paginationOptsValidator,
@@ -3211,6 +3214,7 @@ export const searchPackageCatalogPublic = query({
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
),
isOfficial: v.optional(v.boolean()),
highlightedOnly: v.optional(v.boolean()),
executesCode: v.optional(v.boolean()),
capabilityTag: v.optional(v.string()),
},
+1 -2
View File
@@ -394,12 +394,11 @@ export const list = query({
});
export const listPublic = query({
args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
args: { limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const limit = clampInt(args.limit ?? 40, 1, 100);
const result = await queryUsersForPublicList(ctx, {
limit,
search: args.search,
});
return {
items: result.items
+1
View File
@@ -15,6 +15,7 @@
"dev": "bun --bun vite dev --port 3000",
"docs:list": "bun scripts/docs-list.ts",
"format": "oxfmt --write",
"format:check": "oxfmt --check",
"install:local-hooks": "bun scripts/install-git-hooks.mjs",
"lint": "bun run lint:oxlint",
"lint:fix": "oxlint --type-aware --tsconfig ./tsconfig.oxlint.json ./src ./convex ./packages/clawhub/src ./packages/schema/src --fix && bun run format",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawhub",
"version": "0.11.0",
"version": "0.12.0",
"description": "ClawHub CLI \\u2014 install, update, search, and publish skills plus OpenClaw packages.",
"homepage": "https://clawhub.ai",
"bugs": {
@@ -0,0 +1,555 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
export const RESCAN_GUIDANCE_LABEL = "r: rescan-guidance";
export const RESCAN_GUIDANCE_COMMENT_MARKER = "<!-- clawhub-rescan-guidance -->";
export const SUPPRESS_LABEL = "skip-rescan-guidance";
const DEFAULT_REPO = "openclaw/clawhub";
const DEFAULT_LIMIT = 100;
const APPLY_CONFIRM_ENV = "CLAWHUB_RESCAN_GUIDANCE_APPLY";
const explicitIntentRules = [
{
id: "explicit-rescan",
pattern:
/\b(?:re[-\s]?scan|rerun(?:ning)?\s+(?:the\s+)?(?:security\s+)?scan|re-run\s+(?:the\s+)?(?:security\s+)?scan|run\s+(?:the\s+)?(?:security\s+)?scan\s+again|scan\s+again)\b/i,
},
{
id: "re-evaluation",
pattern: /\b(?:re[-\s]?evaluat(?:e|ion)|reassess|re-assess|re[-\s]?review)\b/i,
},
{
id: "reclassification",
pattern:
/\b(?:re[-\s]?classif(?:y|ication)|remove\s+(?:the\s+)?suspicious\s+flag|clear\s+(?:the\s+)?suspicious\s+flag|mark\s+(?:it\s+)?(?:as\s+)?(?:clean|benign))\b/i,
},
{
id: "review-after-fix",
pattern:
/\b(?:security\s+flag\s+review|scan\s+flag\s+review|(?:request(?:ing)?|please)\s+(?:a\s+)?(?:manual\s+)?(?:review|security\s+review)\b[\s\S]{0,120}\b(?:after|fix(?:ed|es|ing)?|updated?|metadata|current\s+version|latest\s+version|new\s+version)|review\s+request\b[\s\S]{0,120}\b(?:after|fix(?:ed|es|ing)?|updated?|metadata|current\s+version|latest\s+version|new\s+version))\b/i,
},
{
id: "fixed-and-still-flagged",
pattern:
/\b(?:(?:after|despite)\s+(?:fixing|fixes|metadata\s+fixes|clarifying|removing)|fix(?:ed|es)?\s+.*\b(?:still|yet)\s+.*\b(?:flagged|suspicious))\b/i,
},
];
const moderationContextRules = [
{
id: "clawhub-asset",
pattern: /\b(?:skill|plugin|package|publisher|published|version|clawhub)\b/i,
},
{
id: "moderation-signal",
pattern:
/\b(?:suspicious|flagged\s+(?:as\s+)?suspicious|security\s+scan|scanner|virustotal|vt\b|openclaw\s+verdict|moderation|malicious|benign|clean)\b/i,
},
];
const negativeContextRules = [
{
id: "auth-login",
pattern: /\b(?:login|log\s+in|sign[-\s]?in|oauth|unauthorized|token|callback)\b/i,
},
{
id: "install-rate-limit",
pattern: /\b(?:install(?:ing)?|rate\s+limit|429|download|npx)\b/i,
},
{
id: "search-indexing",
pattern: /\b(?:search|indexed|indexing|explore|catalog|disappeared|hidden)\b/i,
},
];
export const rescanGuidanceComment = [
RESCAN_GUIDANCE_COMMENT_MARKER,
'Thanks for the report. Please use the "Rescan" button on the skill/plugin page while signed in as the owner.',
"",
"You can also request a fresh scan from the CLI:",
"- Skill: `clawhub skill rescan <slug>`",
"- Plugin/package: `clawhub package rescan <name>`",
"",
"If the content or metadata changed, publish the fixed version first, then request the rescan for the latest release. I'm closing this issue after posting this guidance. If you're still having trouble after rescanning, please reopen this issue with the ClawHub URL, version, and latest scan result.",
].join("\n");
function normalizeLabel(label) {
if (typeof label === "string") return label.trim().toLowerCase();
if (label && typeof label.name === "string") return label.name.trim().toLowerCase();
return "";
}
function issueLabels(issue) {
return Array.isArray(issue.labels) ? issue.labels.map(normalizeLabel).filter(Boolean) : [];
}
function issueState(issue) {
return String(issue.state ?? "")
.trim()
.toUpperCase();
}
function issueText(issue) {
return `${issue.title ?? ""}\n${issue.body ?? ""}`.trim();
}
function matchingRuleIds(rules, text) {
return rules.filter((rule) => rule.pattern.test(text)).map((rule) => rule.id);
}
function commentHash(body) {
return createHash("sha256").update(body).digest("hex");
}
export function classifyRescanRequest(issue) {
const labels = issueLabels(issue);
const state = issueState(issue);
const text = issueText(issue);
if (state && state !== "OPEN") {
return {
matched: false,
matchedRules: [],
reason: `Skipped because issue state is ${state.toLowerCase()}.`,
actions: [],
};
}
if (issue.pull_request || issue.isPullRequest) {
return {
matched: false,
matchedRules: [],
reason: "Skipped because this is a pull request.",
actions: [],
};
}
if (labels.includes(SUPPRESS_LABEL)) {
return {
matched: false,
matchedRules: [],
reason: `Skipped because ${SUPPRESS_LABEL} is present.`,
actions: [],
};
}
if (labels.includes(RESCAN_GUIDANCE_LABEL)) {
return {
matched: false,
matchedRules: [],
reason: `Skipped because ${RESCAN_GUIDANCE_LABEL} is already present.`,
actions: [],
};
}
if (/\b(?:false\s+duplicate|duplicate\s+flag|not\s+a\s+duplicate|duplicate\s+of)\b/i.test(text)) {
return {
matched: false,
matchedRules: [],
reason:
"Skipped because this looks like a duplicate-classification appeal, not a rescan request.",
actions: [],
};
}
const explicitMatches = matchingRuleIds(explicitIntentRules, text);
if (explicitMatches.length === 0) {
return {
matched: false,
matchedRules: [],
reason: "No explicit rescan, re-evaluation, review, or reclassification request found.",
actions: [],
};
}
const contextMatches = matchingRuleIds(moderationContextRules, text);
if (contextMatches.length < moderationContextRules.length) {
return {
matched: false,
matchedRules: explicitMatches,
reason: "Explicit request found, but it lacks ClawHub asset and moderation/scan context.",
actions: [],
};
}
const negativeMatches = matchingRuleIds(negativeContextRules, text);
const hasStrongModerationLanguage =
/\b(?:suspicious|flagged|virustotal|vt\b|malicious|benign|clean|security\s+scan|scanner|moderation)\b/i.test(
text,
);
if (negativeMatches.length > 0 && !hasStrongModerationLanguage) {
return {
matched: false,
matchedRules: [...explicitMatches, ...contextMatches],
reason: `Skipped because it looks like ${negativeMatches.join(", ")} support rather than a moderation rescan request.`,
actions: [],
};
}
const matchedRules = [...explicitMatches, ...contextMatches];
return {
matched: true,
matchedRules,
reason: `Explicit rescan guidance match: ${matchedRules.join(", ")}.`,
actions: planRescanGuidanceActions(),
};
}
export function planRescanGuidanceActions() {
return [
{
type: "add_label",
label: RESCAN_GUIDANCE_LABEL,
},
];
}
export function planCommentForLabeledIssue(issue) {
const labels = issueLabels(issue);
const state = issueState(issue);
if (state && state !== "OPEN") {
return {
matched: false,
matchedRules: [],
reason: `Skipped because issue state is ${state.toLowerCase()}.`,
actions: [],
};
}
if (issue.pull_request || issue.isPullRequest) {
return {
matched: false,
matchedRules: [],
reason: "Skipped because this is a pull request.",
actions: [],
};
}
if (!labels.includes(RESCAN_GUIDANCE_LABEL)) {
return {
matched: false,
matchedRules: [],
reason: `Skipped because ${RESCAN_GUIDANCE_LABEL} is not present.`,
actions: [],
};
}
return {
matched: true,
matchedRules: ["rescan-guidance-label"],
reason: `Matched because ${RESCAN_GUIDANCE_LABEL} is present.`,
actions: [
{
type: "comment",
body: rescanGuidanceComment,
bodySha256: commentHash(rescanGuidanceComment),
},
{
type: "close",
stateReason: "not_planned",
},
],
};
}
function parseArgs(argv) {
const args = {
repo: DEFAULT_REPO,
limit: DEFAULT_LIMIT,
issues: [],
dryRun: true,
json: true,
commentForLabeledIssue: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--repo") {
args.repo = requireValue(argv, (index += 1), "--repo");
} else if (arg === "--limit") {
args.limit = Number.parseInt(requireValue(argv, (index += 1), "--limit"), 10);
} else if (arg === "--issue" || arg === "--item") {
args.issues.push(Number.parseInt(requireValue(argv, (index += 1), arg), 10));
} else if (arg === "--dry-run") {
args.dryRun = true;
} else if (arg === "--apply") {
args.dryRun = false;
} else if (arg === "--comment-for-labeled-issue") {
args.commentForLabeledIssue = true;
} else if (arg === "--help" || arg === "-h") {
args.help = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!Number.isInteger(args.limit) || args.limit < 1) {
throw new Error("--limit must be a positive integer.");
}
if (args.issues.some((issue) => !Number.isInteger(issue) || issue < 1)) {
throw new Error("--issue values must be positive integers.");
}
if (args.commentForLabeledIssue && args.issues.length === 0) {
throw new Error("--comment-for-labeled-issue requires --issue.");
}
if (!args.dryRun && process.env[APPLY_CONFIRM_ENV] !== "1") {
throw new Error(`--apply requires ${APPLY_CONFIRM_ENV}=1.`);
}
return args;
}
function requireValue(argv, index, flag) {
const value = argv[index];
if (!value || value.startsWith("--")) {
throw new Error(`${flag} requires a value.`);
}
return value;
}
function ghJson(args) {
const stdout = execFileSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
return JSON.parse(stdout);
}
function gh(args, input) {
execFileSync("gh", args, {
encoding: "utf8",
input,
maxBuffer: 64 * 1024 * 1024,
stdio: input === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
});
}
function ghOk(args) {
try {
gh(args);
return true;
} catch {
return false;
}
}
function labelApiName(label) {
return encodeURIComponent(label);
}
function ensureGuidanceLabel(repo) {
if (ghOk(["api", `repos/${repo}/labels/${labelApiName(RESCAN_GUIDANCE_LABEL)}`])) return;
const created = ghOk([
"label",
"create",
RESCAN_GUIDANCE_LABEL,
"--repo",
repo,
"--color",
"bfdadc",
"--description",
"Rescan guidance has been posted for this ClawHub item",
]);
if (!created && !ghOk(["api", `repos/${repo}/labels/${labelApiName(RESCAN_GUIDANCE_LABEL)}`])) {
throw new Error(`Could not create or find label: ${RESCAN_GUIDANCE_LABEL}`);
}
}
function normalizeGhIssue(issue) {
return {
number: issue.number,
title: issue.title ?? "",
body: issue.body ?? "",
state: issue.state ?? "",
url: issue.url ?? issue.html_url ?? "",
labels: issue.labels ?? [],
};
}
async function fetchIssues(options) {
if (options.issues.length > 0) {
return options.issues.map((issueNumber) =>
normalizeGhIssue(
ghJson([
"issue",
"view",
String(issueNumber),
"--repo",
options.repo,
"--json",
"number,title,body,state,url,labels",
]),
),
);
}
return ghJson([
"issue",
"list",
"--repo",
options.repo,
"--state",
"open",
"--limit",
String(options.limit),
"--json",
"number,title,body,state,url,labels",
]).map(normalizeGhIssue);
}
export function planIssue(issue) {
const classification = classifyRescanRequest(issue);
return {
number: issue.number,
title: issue.title,
url: issue.url,
matched: classification.matched,
matchedRules: classification.matchedRules,
reason: classification.reason,
actions: classification.actions,
};
}
function writeCommentPayload(plan) {
const commentAction = plan.actions.find((action) => action.type === "comment");
if (!commentAction) return null;
return JSON.stringify({ body: commentAction.body });
}
function hasExistingGuidanceComment(repo, number) {
const comments = ghJson([
"api",
`repos/${repo}/issues/${number}/comments?per_page=100`,
"--jq",
`[.[] | {body}]`,
]);
return comments.some((comment) =>
String(comment.body ?? "").includes(RESCAN_GUIDANCE_COMMENT_MARKER),
);
}
function applyPlan(plan, options) {
if (!plan.matched) return { number: plan.number, applied: false, reason: plan.reason };
if (plan.actions.some((action) => action.type === "add_label")) {
ensureGuidanceLabel(options.repo);
}
const existingGuidanceComment = hasExistingGuidanceComment(options.repo, plan.number);
const appliedActions = [];
for (const action of plan.actions) {
if (action.type === "add_label") {
gh([
"api",
`repos/${options.repo}/issues/${plan.number}/labels`,
"--method",
"POST",
"--field",
`labels[]=${action.label}`,
]);
appliedActions.push(action.type);
} else if (action.type === "comment") {
if (existingGuidanceComment) continue;
gh(
[
"api",
`repos/${options.repo}/issues/${plan.number}/comments`,
"--method",
"POST",
"--input",
"-",
],
writeCommentPayload(plan),
);
appliedActions.push(action.type);
} else if (action.type === "close") {
gh(
["api", `repos/${options.repo}/issues/${plan.number}`, "--method", "PATCH", "--input", "-"],
JSON.stringify({ state: "closed", state_reason: action.stateReason ?? "not_planned" }),
);
appliedActions.push(action.type);
}
}
return {
number: plan.number,
applied: appliedActions.length > 0,
actions: appliedActions,
skippedComment: existingGuidanceComment,
};
}
function renderSummary(plans, options) {
const matches = plans.filter((plan) => plan.matched);
const lines = [
`ClawHub rescan auto-response ${options.dryRun ? "dry run" : "apply run"} for ${options.repo}`,
`Scanned ${plans.length} issue(s); matched ${matches.length}.`,
];
for (const plan of matches) {
lines.push(`- #${plan.number}: ${plan.title}`);
lines.push(` ${plan.url}`);
lines.push(` rules: ${plan.matchedRules.join(", ")}`);
}
return lines.join("\n");
}
function helpText() {
return [
"Usage: bun scripts/github/clawhub-rescan-auto-response.mjs [options]",
"",
"Options:",
" --repo <owner/repo> Repository to inspect. Default: openclaw/clawhub",
" --limit <n> Number of open issues to scan. Default: 100",
" --issue <n> Inspect one issue number. Repeatable.",
" --dry-run Preview only. Default.",
" --comment-for-labeled-issue",
` Post guidance only when ${RESCAN_GUIDANCE_LABEL} is already present.`,
` --apply Add the label and guidance comment. Requires ${APPLY_CONFIRM_ENV}=1.`,
" --help Show this help.",
].join("\n");
}
export async function runCli(argv = process.argv.slice(2)) {
const options = parseArgs(argv);
if (options.help) {
console.log(helpText());
return;
}
const issues = await fetchIssues(options);
const plans = options.commentForLabeledIssue
? issues.map((issue) => {
const classification = planCommentForLabeledIssue(issue);
return {
number: issue.number,
title: issue.title,
url: issue.url,
matched: classification.matched,
matchedRules: classification.matchedRules,
reason: classification.reason,
actions: classification.actions,
};
})
: issues.map(planIssue);
const applyResults = options.dryRun ? [] : plans.map((plan) => applyPlan(plan, options));
console.error(renderSummary(plans, options));
console.log(
JSON.stringify(
{
repo: options.repo,
dryRun: options.dryRun,
scanned: plans.length,
matched: plans.filter((plan) => plan.matched).length,
applied: applyResults.filter((result) => result.applied).length,
applyResults,
plans,
},
null,
2,
),
);
}
const currentFile = fileURLToPath(import.meta.url);
if (process.argv[1] === currentFile) {
runCli().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}
@@ -0,0 +1,191 @@
/* @vitest-environment node */
import { describe, expect, it } from "vitest";
import {
RESCAN_GUIDANCE_COMMENT_MARKER,
RESCAN_GUIDANCE_LABEL,
SUPPRESS_LABEL,
classifyRescanRequest,
planCommentForLabeledIssue,
planIssue,
rescanGuidanceComment,
} from "./clawhub-rescan-auto-response.mjs";
const issue = (overrides) => ({
number: 1,
title: "placeholder",
body: "",
state: "OPEN",
url: "https://github.com/openclaw/clawhub/issues/1",
labels: [],
...overrides,
});
describe("clawhub rescan auto-response classifier", () => {
it.each([
[
1553,
"Re-scan jarviyin/clawpk v5.0.0 - remove suspicious flag",
"Please re-run the security scan on v5.0.0 and remove the suspicious flag. The package no longer contains any patterns that should trigger it.",
],
[
1834,
"feishu-team-manager: Request re-scan after fixing flagged issues (v2.4.3)",
"The skill fixed credentials and Unicode control characters. Please re-run the security scan on v2.4.3.",
],
[
1808,
"Re-evaluation request: topview-skill (official Topview AI client) - medium-suspicious verdict triggered by emoji ZWJ false positive",
"Please re-scan at the current commit and reclassify as Benign. The suspicious scan findings have been fixed.",
],
[
1671,
'Request for Security Re-evaluation: "book-companion" skill marked as suspicious',
"I have proactively audited the skill and implemented compliance measures. Please review the updated documentation and clear the suspicious flag.",
],
])("matches explicit rescan/re-evaluation request #%s", (number, title, body) => {
const result = classifyRescanRequest(issue({ number, title, body }));
expect(result.matched).toBe(true);
expect(result.matchedRules.length).toBeGreaterThanOrEqual(3);
expect(result.actions).toEqual([{ type: "add_label", label: RESCAN_GUIDANCE_LABEL }]);
expect(rescanGuidanceComment).toContain(RESCAN_GUIDANCE_COMMENT_MARKER);
});
it.each([
[
589,
"Rate limit exceeded when installing clawhub",
"npx clawhub@latest install sonoscli returns Rate limit exceeded. Is this not getting fixed?",
],
[
100,
"CLI: Auth fails due to redirect from clawhub.ai to www.clawhub.ai",
"The clawhub CLI fails to authenticate because a redirect loses the Authorization header.",
],
[
758,
"False positive: create-project skill flagged as suspicious by VirusTotal",
"The create-project skill has been flagged as suspicious. This appears to be the same class of false positive as other issues.",
],
[
256,
"False positive: clawarr-suite flagged as suspicious",
"Please review and unflag. All patterns are standard for a media server management tool.",
],
[
1514,
"False duplicate flag: claude-to-free is not a duplicate of model-migration",
"This skill is not a duplicate. Please remove the duplicate flag.",
],
])("does not match non-rescan issue #%s", (number, title, body) => {
const result = classifyRescanRequest(issue({ number, title, body }));
expect(result.matched).toBe(false);
expect(result.actions).toEqual([]);
});
it("skips closed issues", () => {
const result = classifyRescanRequest(
issue({
state: "CLOSED",
title: "Re-scan example skill",
body: "Please re-run the security scan and remove the suspicious flag.",
}),
);
expect(result.matched).toBe(false);
expect(result.reason).toContain("closed");
});
it("skips pull requests", () => {
const result = classifyRescanRequest(
issue({
isPullRequest: true,
title: "Re-scan example skill",
body: "Please re-run the security scan and remove the suspicious flag.",
}),
);
expect(result.matched).toBe(false);
expect(result.reason).toContain("pull request");
});
it("skips suppressed and already-handled issues", () => {
expect(
classifyRescanRequest(
issue({
labels: [{ name: SUPPRESS_LABEL }],
title: "Re-scan example skill",
body: "Please re-run the security scan and remove the suspicious flag.",
}),
).reason,
).toContain(SUPPRESS_LABEL);
expect(
classifyRescanRequest(
issue({
labels: [{ name: RESCAN_GUIDANCE_LABEL }],
title: "Re-scan example skill",
body: "Please re-run the security scan and remove the suspicious flag.",
}),
).reason,
).toContain(RESCAN_GUIDANCE_LABEL);
});
it("plans dry-run rows for matched issues", () => {
const plan = planIssue(
issue({
number: 1834,
title: "feishu-team-manager: Request re-scan after fixing flagged issues (v2.4.3)",
body: "This skill has fixed flagged metadata issues. Please re-run the security scan on v2.4.3.",
}),
);
expect(plan).toMatchObject({
number: 1834,
matched: true,
actions: [{ type: "add_label", label: RESCAN_GUIDANCE_LABEL }],
});
});
it("plans comments only for issues already labeled for guidance", () => {
const plan = planCommentForLabeledIssue(
issue({
labels: [{ name: RESCAN_GUIDANCE_LABEL }],
title: "False positive: example skill flagged as suspicious",
body: "Please re-run the security scan.",
}),
);
expect(plan).toMatchObject({
matched: true,
matchedRules: ["rescan-guidance-label"],
actions: [
{
type: "comment",
body: rescanGuidanceComment,
bodySha256: expect.any(String),
},
{
type: "close",
stateReason: "not_planned",
},
],
});
expect(rescanGuidanceComment).toContain("reopen this issue");
});
it("does not plan comments without the guidance label", () => {
const plan = planCommentForLabeledIssue(
issue({
title: "False positive: example skill flagged as suspicious",
body: "Please re-run the security scan.",
}),
);
expect(plan).toMatchObject({
matched: false,
actions: [],
});
});
});
+287 -133
View File
@@ -1,198 +1,352 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import Header from "../components/Header";
import { beforeEach, describe, expect, it, vi } from "vitest";
type HeaderAuthStatus = {
isAuthenticated: boolean;
isLoading: boolean;
me: Record<string, unknown> | null;
};
const siteModeMock = vi.fn(() => "souls");
const navigateMock = vi.fn();
const { useUnifiedSearchMock } = vi.hoisted(() => ({
useUnifiedSearchMock: vi.fn(),
}));
const defaultUnifiedSearchResult = {
results: [],
skillResults: [
{
type: "skill",
ownerHandle: "local",
score: 10,
skill: {
_id: "skills:weather",
slug: "weather",
displayName: "Weather Skill",
ownerUserId: "users:local",
stats: { downloads: 1, stars: 2 },
createdAt: 1,
updatedAt: 2,
},
},
],
pluginResults: [
{
type: "plugin",
plugin: {
name: "weather-plugin",
displayName: "Weather Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
summary: "Plugin weather tools.",
ownerHandle: "local",
createdAt: 1,
updatedAt: 2,
latestVersion: "1.0.0",
capabilityTags: [],
executesCode: true,
verificationTier: null,
},
},
],
skillCount: 1,
pluginCount: 1,
isSearching: false,
};
vi.mock("@tanstack/react-router", () => ({
Link: (props: {
children: ReactNode;
className?: string;
hash?: string;
to?: string;
}) => (
<a
href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`}
className={props.className}
>
{props.children}
</a>
),
useLocation: () => ({ pathname: "/" }),
useNavigate: () => navigateMock,
Link: (props: { children: ReactNode; className?: string; hash?: string; to?: string }) => (
<a href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`} className={props.className}>
{props.children}
</a>
),
useLocation: () => ({ pathname: "/" }),
useNavigate: () => navigateMock,
}));
vi.mock("@convex-dev/auth/react", () => ({
useAuthActions: () => ({
signIn: vi.fn(),
signOut: vi.fn(),
}),
useAuthActions: () => ({
signIn: vi.fn(),
signOut: vi.fn(),
}),
}));
const authStatusMock = vi.fn(() => ({
isAuthenticated: false,
isLoading: false,
me: null,
const authStatusMock = vi.fn<() => HeaderAuthStatus>(() => ({
isAuthenticated: false,
isLoading: false,
me: null,
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => authStatusMock(),
useAuthStatus: () => authStatusMock(),
}));
const setThemeMock = vi.fn();
const setModeMock = vi.fn();
vi.mock("../lib/theme", () => ({
applyTheme: vi.fn(),
THEME_OPTIONS: [
{ value: "claw", label: "Claw", description: "" },
{ value: "hub", label: "Hub", description: "" },
],
useThemeMode: () => ({
theme: "hub",
mode: "system",
setTheme: setThemeMock,
setMode: setModeMock,
}),
applyTheme: vi.fn(),
THEME_OPTIONS: [
{ value: "claw", label: "Claw", description: "" },
{ value: "hub", label: "Hub", description: "" },
],
useThemeMode: () => ({
theme: "hub",
mode: "system",
setTheme: setThemeMock,
setMode: setModeMock,
}),
}));
vi.mock("../lib/theme-transition", () => ({
startThemeTransition: ({
setTheme,
nextTheme,
}: {
setTheme: (value: string) => void;
nextTheme: string;
}) => setTheme(nextTheme),
startThemeTransition: ({
setTheme,
nextTheme,
}: {
setTheme: (value: string) => void;
nextTheme: string;
}) => setTheme(nextTheme),
}));
vi.mock("../lib/useAuthError", () => ({
setAuthError: vi.fn(),
useAuthError: () => ({
error: null,
clear: vi.fn(),
}),
setAuthError: vi.fn(),
useAuthError: () => ({
error: null,
clear: vi.fn(),
}),
}));
vi.mock("../lib/roles", () => ({
isModerator: () => false,
isModerator: () => false,
}));
vi.mock("../lib/site", () => ({
getClawHubSiteUrl: () => "https://clawhub.ai",
getSiteMode: () => siteModeMock(),
getSiteName: () => "OnlyCrabs",
getClawHubSiteUrl: () => "https://clawhub.ai",
getSiteMode: () => siteModeMock(),
getSiteName: () => "OnlyCrabs",
}));
vi.mock("../lib/gravatar", () => ({
gravatarUrl: vi.fn(),
gravatarUrl: vi.fn(),
}));
vi.mock("../lib/useUnifiedSearch", () => ({
useUnifiedSearch: () => useUnifiedSearchMock(),
}));
vi.mock("../components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuItem: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuSeparator: () => <hr />,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuSeparator: () => <hr />,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("../components/ui/toggle-group", () => ({
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
<button type="button">{children}</button>
),
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
<button type="button">{children}</button>
),
}));
import Header from "../components/Header";
describe("Header", () => {
it("hides Packages navigation in soul mode on mobile and desktop", () => {
siteModeMock.mockReturnValue("souls");
beforeEach(() => {
authStatusMock.mockReturnValue({
isAuthenticated: false,
isLoading: false,
me: null,
});
siteModeMock.mockReturnValue("souls");
useUnifiedSearchMock.mockReturnValue(defaultUnifiedSearchResult);
});
render(<Header />);
it("hides Packages navigation in soul mode on mobile and desktop", () => {
siteModeMock.mockReturnValue("souls");
expect(screen.queryByText("Packages")).toBeNull();
});
render(<Header />);
it("renders simplified desktop nav and theme toggle", () => {
siteModeMock.mockReturnValue("skills");
setThemeMock.mockClear();
setModeMock.mockClear();
expect(screen.queryByText("Packages")).toBeNull();
});
render(<Header />);
it("renders simplified desktop nav and theme toggle", () => {
siteModeMock.mockReturnValue("skills");
setThemeMock.mockClear();
setModeMock.mockClear();
expect(
screen.getByRole("button", { name: /Toggle theme\. Current: system/i }),
).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);
expect(screen.getAllByText("Plugins")).toHaveLength(1);
expect(screen.queryByText("Users")).toBeNull();
expect(screen.queryByText("Dashboard")).toBeNull();
expect(screen.queryByText("Manage")).toBeNull();
expect(
screen.getByPlaceholderText("Search skills, plugins, users"),
).toBeTruthy();
render(<Header />);
fireEvent.click(
screen.getByRole("button", { name: /Toggle theme\. Current: system/i }),
);
expect(setModeMock).toHaveBeenCalledWith("dark");
expect(screen.getByRole("button", { name: /Toggle theme\. Current: system/i })).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);
expect(screen.getAllByText("Plugins")).toHaveLength(1);
expect(screen.queryByText("Users")).toBeNull();
expect(screen.queryByText("Dashboard")).toBeNull();
expect(screen.queryByText("Manage")).toBeNull();
expect(screen.getByPlaceholderText("Search skills and plugins")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
fireEvent.click(screen.getByRole("button", { name: /Toggle theme\. Current: system/i }));
expect(setModeMock).toHaveBeenCalledWith("dark");
expect(screen.getAllByText("Home")).toHaveLength(1);
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Plugins")).toHaveLength(2);
});
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
it("shows Home above Skills in the mobile menu", () => {
siteModeMock.mockReturnValue("skills");
expect(screen.getAllByText("Home")).toHaveLength(1);
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Plugins")).toHaveLength(2);
});
render(<Header />);
it("shows grouped skills and plugins typeahead without users", () => {
siteModeMock.mockReturnValue("skills");
navigateMock.mockReset();
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
render(<Header />);
expect(document.querySelector(".mobile-nav-brand-mark-image")).toBeTruthy();
const input = screen.getByPlaceholderText("Search skills and plugins");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "weather" } });
const labels = Array.from(
document.querySelectorAll(".mobile-nav-section .mobile-nav-link"),
)
.map((element) => element.textContent?.trim())
.filter((label): label is string => Boolean(label));
const typeahead = screen.getByRole("listbox");
expect(within(typeahead).getByText("Skills")).toBeTruthy();
expect(screen.getByText("Weather Skill")).toBeTruthy();
expect(within(typeahead).getByText("Plugins")).toBeTruthy();
expect(screen.getByText("Weather Plugin")).toBeTruthy();
expect(within(typeahead).queryByText("Users")).toBeNull();
expect(within(typeahead).queryByText('See user results for "weather"')).toBeNull();
expect(labels.slice(0, 2)).toEqual(["Home", "Skills"]);
});
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
it("routes soul-mode header searches to the souls browse page", () => {
siteModeMock.mockReturnValue("souls");
navigateMock.mockReset();
expect(navigateMock).toHaveBeenCalledWith({
to: "/search",
search: { q: "weather", type: "skills" },
});
});
render(<Header />);
it("falls back to typed skill search when a typeahead skill has no owner handle", () => {
siteModeMock.mockReturnValue("skills");
navigateMock.mockReset();
useUnifiedSearchMock.mockReturnValue({
...defaultUnifiedSearchResult,
skillResults: [
{
...defaultUnifiedSearchResult.skillResults[0],
ownerHandle: null,
skill: {
...defaultUnifiedSearchResult.skillResults[0].skill,
ownerUserId: "users:opaque-id",
ownerPublisherId: "publishers:opaque-id",
},
},
],
pluginResults: [],
pluginCount: 0,
});
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
target: { value: "angler" },
});
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
render(<Header />);
expect(navigateMock).toHaveBeenCalledWith({
to: "/souls",
search: {
q: "angler",
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
},
});
});
const input = screen.getByPlaceholderText("Search skills and plugins");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "weather" } });
fireEvent.click(screen.getByRole("option", { name: /Weather Skill/i }));
expect(navigateMock).toHaveBeenCalledWith({
to: "/search",
search: { q: "weather", type: "skills" },
});
expect(navigateMock).not.toHaveBeenCalledWith(
expect.objectContaining({
to: "/publishers%3Aopaque-id/weather",
}),
);
});
it("shows a single no-results state without section footers", () => {
siteModeMock.mockReturnValue("skills");
useUnifiedSearchMock.mockReturnValue({
results: [],
skillResults: [],
pluginResults: [],
skillCount: 0,
pluginCount: 0,
isSearching: false,
});
render(<Header />);
const input = screen.getByPlaceholderText("Search skills and plugins");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "zzzz" } });
const typeahead = screen.getByRole("listbox");
expect(within(typeahead).getByText('No skills or plugins found for "zzzz"')).toBeTruthy();
expect(within(typeahead).queryByText("Skills")).toBeNull();
expect(within(typeahead).queryByText("Plugins")).toBeNull();
expect(within(typeahead).queryByText('See skill results for "zzzz"')).toBeNull();
expect(within(typeahead).queryByText('See plugin results for "zzzz"')).toBeNull();
});
it("shows Home above Skills in the mobile menu", () => {
siteModeMock.mockReturnValue("skills");
render(<Header />);
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
expect(document.querySelector(".mobile-nav-brand-mark-image")).toBeTruthy();
const labels = Array.from(document.querySelectorAll(".mobile-nav-section .mobile-nav-link"))
.map((element) => element.textContent?.trim())
.filter((label): label is string => Boolean(label));
expect(labels.slice(0, 2)).toEqual(["Home", "Skills"]);
});
it("keeps Stars out of signed-in header navigation", () => {
siteModeMock.mockReturnValue("skills");
authStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: {
displayName: "Patrick",
email: "patrick@example.com",
handle: "patrick",
image: null,
name: "Patrick",
},
});
render(<Header />);
expect(screen.queryByText("Stars")).toBeNull();
expect(screen.getAllByText("Dashboard").length).toBeGreaterThan(0);
expect(screen.getByText("Settings")).toBeTruthy();
});
it("routes soul-mode header searches to the souls browse page", () => {
siteModeMock.mockReturnValue("souls");
navigateMock.mockReset();
render(<Header />);
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
target: { value: "angler" },
});
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
expect(navigateMock).toHaveBeenCalledWith({
to: "/souls",
search: {
q: "angler",
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
},
});
});
});
+28 -6
View File
@@ -5,6 +5,7 @@ import type { ComponentType, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const fetchPluginCatalogMock = vi.fn();
const fetchFeaturedPluginsMock = vi.fn();
const isRateLimitedPackageApiErrorMock = vi.fn(
(error: unknown) =>
typeof error === "object" && error !== null && (error as { status?: number }).status === 429,
@@ -57,6 +58,10 @@ vi.mock("../lib/packageApi", () => ({
isRateLimitedPackageApiError: (error: unknown) => isRateLimitedPackageApiErrorMock(error),
}));
vi.mock("../lib/featuredCatalog", () => ({
fetchFeaturedPlugins: (...args: unknown[]) => fetchFeaturedPluginsMock(...args),
}));
async function loadRoute() {
return (await import("../routes/plugins/index")).Route as unknown as {
__config: {
@@ -70,6 +75,7 @@ async function loadRoute() {
describe("plugins route", () => {
beforeEach(() => {
fetchPluginCatalogMock.mockReset();
fetchFeaturedPluginsMock.mockReset();
isRateLimitedPackageApiErrorMock.mockClear();
navigateMock.mockReset();
searchMock = {};
@@ -92,6 +98,7 @@ describe("plugins route", () => {
family: undefined,
q: "demo",
cursor: undefined,
featured: undefined,
verified: undefined,
executesCode: undefined,
});
@@ -211,12 +218,29 @@ describe("plugins route", () => {
);
});
it("selects featured from the sort group", async () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
fireEvent.click(screen.getByRole("radio", { name: "Featured" }));
expect(navigateMock).toHaveBeenCalled();
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
};
expect(lastCall.search({ family: "code-plugin", cursor: "cursor:current" })).toEqual({
family: undefined,
cursor: undefined,
featured: true,
});
});
it("returns a retryable empty state when the catalog is rate limited", async () => {
fetchPluginCatalogMock.mockRejectedValue({ status: 429, retryAfterSeconds: 22 });
const route = await loadRoute();
const loader = route.__config.loader as (args: {
deps: Record<string, unknown>;
}) => Promise<{
const loader = route.__config.loader as (args: { deps: Record<string, unknown> }) => Promise<{
items: Array<{ name: string }>;
nextCursor: string | null;
rateLimited: boolean;
@@ -237,9 +261,7 @@ describe("plugins route", () => {
it("flags API errors for filtered catalog requests", async () => {
fetchPluginCatalogMock.mockRejectedValue(new Error("boom"));
const route = await loadRoute();
const loader = route.__config.loader as (args: {
deps: Record<string, unknown>;
}) => Promise<{
const loader = route.__config.loader as (args: { deps: Record<string, unknown> }) => Promise<{
items: Array<{ name: string }>;
nextCursor: string | null;
rateLimited: boolean;
+2 -3
View File
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
vi.mock("../convex/client", () => ({
convex: {},
convexHttp: { query: vi.fn() },
@@ -51,10 +50,10 @@ describe("search route", () => {
});
});
it("accepts the users type filter", () => {
it("ignores the users type filter", () => {
expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({
q: "vincent",
type: "users",
type: undefined,
});
});
+18 -14
View File
@@ -5,24 +5,23 @@ import type { ComponentType, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const navigateMock = vi.fn();
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" | "users" } = {};
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" } = {};
vi.mock("@tanstack/react-router", () => ({
createFileRoute:
() =>
(config: { component?: unknown; validateSearch?: unknown }) => ({
__config: config,
useSearch: () => searchMock,
}),
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
__config: config,
useSearch: () => searchMock,
}),
useNavigate: () => navigateMock,
}));
vi.mock("../lib/useUnifiedSearch", () => ({
useUnifiedSearch: () => ({
results: [],
skillResults: [],
pluginResults: [],
skillCount: 0,
pluginCount: 0,
userCount: 0,
isSearching: false,
}),
}));
@@ -35,10 +34,6 @@ vi.mock("../components/SkillListItem", () => ({
SkillListItem: ({ skill }: { skill: { slug: string } }) => <div>{skill.slug}</div>,
}));
vi.mock("../components/UserListItem", () => ({
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
}));
vi.mock("../components/ui/card", () => ({
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
@@ -64,7 +59,7 @@ describe("search route", () => {
const Component = route.__config.component as ComponentType;
const rendered = render(<Component />);
const input = screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement;
const input = screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement;
expect(input.value).toBe("first");
fireEvent.change(input, { target: { value: "draft" } });
@@ -74,7 +69,16 @@ describe("search route", () => {
rendered.rerender(<Component />);
expect(
(screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement).value,
(screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement).value,
).toBe("second");
});
it("does not render a public users search tab", async () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.queryByRole("button", { name: /users/i })).toBeNull();
});
});
@@ -40,7 +40,7 @@ function runBeforeLoad(search: Record<string, unknown>) {
describe("skills route default sort", () => {
it("redirects browse view to downloads when sort is missing", () => {
expect(runBeforeLoad({ nonSuspicious: true })).toEqual({
expect(runBeforeLoad({})).toEqual({
redirect: {
to: "/skills",
search: {
@@ -48,7 +48,8 @@ describe("skills route default sort", () => {
sort: "downloads",
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
featured: undefined,
nonSuspicious: undefined,
tag: undefined,
view: undefined,
focus: undefined,
@@ -61,4 +62,10 @@ describe("skills route default sort", () => {
it("does not redirect when query is present", () => {
expect(runBeforeLoad({ q: "notion" })).toBeUndefined();
});
it("does not redirect when filters are present", () => {
expect(runBeforeLoad({ nonSuspicious: true })).toBeUndefined();
expect(runBeforeLoad({ featured: true })).toBeUndefined();
expect(runBeforeLoad({ highlighted: true })).toBeUndefined();
});
});
+56
View File
@@ -0,0 +1,56 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from "@testing-library/react";
import type { ComponentType, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const queryMock = vi.fn();
vi.mock("../convex/client", () => ({
convexHttp: { query: (...args: unknown[]) => queryMock(...args) },
}));
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
__config: config,
}),
}));
vi.mock("../components/UserListItem", () => ({
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
}));
vi.mock("../components/ui/card", () => ({
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
async function loadRoute() {
return (await import("../routes/users/index")).Route as unknown as {
__config: {
component?: ComponentType;
validateSearch?: unknown;
};
};
}
describe("users route", () => {
beforeEach(() => {
vi.resetModules();
queryMock.mockReset();
queryMock.mockResolvedValue({ items: [], total: 0 });
});
it("does not expose public user search", async () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
await waitFor(() => expect(queryMock).toHaveBeenCalled());
expect(queryMock.mock.calls[0]?.[1]).toEqual({ limit: 48 });
expect(screen.queryByPlaceholderText(/search users/i)).toBeNull();
expect(route.__config.validateSearch).toBeUndefined();
});
});
+347 -24
View File
@@ -1,19 +1,20 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { type ComponentType, useMemo, useState } from "react";
import { ArrowRight, Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { type ComponentType, useEffect, useMemo, useRef, useState } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import { gravatarUrl } from "../lib/gravatar";
import {
filterNavItems,
type NavIconName,
PRIMARY_NAV_ITEMS,
} from "../lib/nav-items";
import { filterNavItems, type NavIconName, PRIMARY_NAV_ITEMS } from "../lib/nav-items";
import { isModerator } from "../lib/roles";
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
import { applyTheme, useThemeMode } from "../lib/theme";
import { setAuthError, useAuthError } from "../lib/useAuthError";
import { useAuthStatus } from "../lib/useAuthStatus";
import {
useUnifiedSearch,
type UnifiedPluginResult,
type UnifiedSkillResult,
} from "../lib/useUnifiedSearch";
import { Button } from "./ui/button";
import {
DropdownMenu,
@@ -37,6 +38,24 @@ const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?:
ghost: Ghost,
};
type TypeaheadItem =
| {
kind: "skill";
key: string;
result: UnifiedSkillResult;
}
| {
kind: "plugin";
key: string;
result: UnifiedPluginResult;
}
| {
kind: "footer";
key: string;
section: "skills" | "plugins";
label: string;
};
export default function Header() {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const { signIn, signOut } = useAuthActions();
@@ -62,10 +81,67 @@ export default function Header() {
const signInRedirectTo = getCurrentRelativeUrl();
const [navSearchQuery, setNavSearchQuery] = useState("");
const [typeaheadOpen, setTypeaheadOpen] = useState(false);
const [typeaheadActiveIndex, setTypeaheadActiveIndex] = useState(0);
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const searchWrapRef = useRef<HTMLDivElement | null>(null);
const ThemeModeIcon = getThemeModeIcon(mode);
const nextThemeMode = getNextThemeMode(mode);
const trimmedNavSearchQuery = navSearchQuery.trim();
const showTypeahead = !isSoulMode && typeaheadOpen && trimmedNavSearchQuery.length > 0;
const {
skillResults,
skillCount,
pluginResults,
pluginCount,
isSearching: typeaheadSearching,
} = useUnifiedSearch(navSearchQuery, "all", {
debounceMs: 180,
enabled: showTypeahead,
limits: { skills: 4, plugins: 4 },
});
const typeaheadItems = useMemo<TypeaheadItem[]>(() => {
if (!showTypeahead) return [];
const items: TypeaheadItem[] = [];
for (const result of skillResults) {
items.push({ kind: "skill", key: `skill-${result.skill._id}`, result });
}
if (skillCount > 0) {
items.push({
kind: "footer",
key: "footer-skills",
section: "skills",
label: `See skill results for "${trimmedNavSearchQuery}"`,
});
}
for (const result of pluginResults) {
items.push({ kind: "plugin", key: `plugin-${result.plugin.name}`, result });
}
if (pluginCount > 0) {
items.push({
kind: "footer",
key: "footer-plugins",
section: "plugins",
label: `See plugin results for "${trimmedNavSearchQuery}"`,
});
}
return items;
}, [pluginCount, pluginResults, showTypeahead, skillCount, skillResults, trimmedNavSearchQuery]);
useEffect(() => {
setTypeaheadActiveIndex(0);
}, [trimmedNavSearchQuery]);
useEffect(() => {
if (!typeaheadOpen) return () => {};
const handlePointerDown = (event: PointerEvent) => {
if (searchWrapRef.current?.contains(event.target as Node)) return;
setTypeaheadOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [typeaheadOpen]);
const setThemeMode = (next: "system" | "light" | "dark") => {
applyTheme(next, theme);
@@ -89,9 +165,72 @@ export default function Header() {
: { q, type: undefined },
});
setNavSearchQuery("");
setTypeaheadOpen(false);
setMobileSearchOpen(false);
};
const navigateToTypeaheadItem = (item: TypeaheadItem) => {
if (item.kind === "skill") {
const resultOwnerHandle = item.result.ownerHandle?.trim();
if (!resultOwnerHandle) {
void navigate({
to: "/search",
search: { q: trimmedNavSearchQuery, type: "skills" },
});
setNavSearchQuery("");
setTypeaheadOpen(false);
setMobileSearchOpen(false);
return;
}
void navigate({
to: `/${encodeURIComponent(resultOwnerHandle)}/${encodeURIComponent(item.result.skill.slug)}`,
});
} else if (item.kind === "plugin") {
void navigate({
to: "/plugins/$name",
params: { name: item.result.plugin.name },
});
} else {
void navigate({
to: "/search",
search: { q: trimmedNavSearchQuery, type: item.section },
});
}
setNavSearchQuery("");
setTypeaheadOpen(false);
setMobileSearchOpen(false);
};
const handleSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (isSoulMode) return;
if (event.key === "Escape") {
setTypeaheadOpen(false);
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Enter") return;
if (!showTypeahead || typeaheadItems.length === 0) {
if (event.key === "ArrowDown" && trimmedNavSearchQuery) {
setTypeaheadOpen(true);
event.preventDefault();
}
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setTypeaheadActiveIndex((index) => (index + 1) % typeaheadItems.length);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setTypeaheadActiveIndex(
(index) => (index - 1 + typeaheadItems.length) % typeaheadItems.length,
);
} else if (event.key === "Enter") {
const activeItem = typeaheadItems[typeaheadActiveIndex];
if (!activeItem) return;
event.preventDefault();
navigateToTypeaheadItem(activeItem);
}
};
return (
<header className="navbar">
<div className="navbar-inner">
@@ -176,17 +315,42 @@ export default function Header() {
<span className="brand-name brand-name-responsive">{siteName}</span>
</Link>
<form className="navbar-search" onSubmit={handleNavSearch} role="search" aria-label="Site search">
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="navbar-search-input"
type="search"
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
value={navSearchQuery}
onChange={(e) => setNavSearchQuery(e.target.value)}
aria-label="Search"
/>
</form>
<div className="navbar-search-wrap" ref={searchWrapRef}>
<form
className="navbar-search"
onSubmit={handleNavSearch}
role="search"
aria-label="Site search"
>
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="navbar-search-input"
type="search"
placeholder={isSoulMode ? "Search souls..." : "Search skills and plugins"}
value={navSearchQuery}
onChange={(e) => {
setNavSearchQuery(e.target.value);
setTypeaheadOpen(true);
}}
onFocus={() => setTypeaheadOpen(true)}
onKeyDown={handleSearchKeyDown}
aria-label="Search"
aria-expanded={showTypeahead}
aria-controls="navbar-search-typeahead"
autoComplete="off"
/>
</form>
{showTypeahead ? (
<SearchTypeahead
activeIndex={typeaheadActiveIndex}
items={typeaheadItems}
loading={typeaheadSearching}
onHoverItem={setTypeaheadActiveIndex}
onSelectItem={navigateToTypeaheadItem}
query={trimmedNavSearchQuery}
/>
) : null}
</div>
<nav className="navbar-top-links" aria-label="Primary">
{isSoulMode ? (
@@ -248,9 +412,6 @@ export default function Header() {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to="/stars">Stars</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/dashboard">Dashboard</Link>
</DropdownMenuItem>
@@ -287,7 +448,9 @@ export default function Header() {
"github",
signInRedirectTo ? { redirectTo: signInRedirectTo } : undefined,
).catch((error) => {
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
setAuthError(
getUserFacingAuthError(error, "Sign in failed. Please try again."),
);
});
}}
>
@@ -305,19 +468,179 @@ export default function Header() {
<input
className="navbar-search-input"
type="text"
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
placeholder={isSoulMode ? "Search souls..." : "Search skills and plugins"}
value={navSearchQuery}
onChange={(e) => setNavSearchQuery(e.target.value)}
autoFocus
/>
</form>
) : null}
</div>
</header>
);
}
function SearchTypeahead({
activeIndex,
items,
loading,
onHoverItem,
onSelectItem,
query,
}: {
activeIndex: number;
items: TypeaheadItem[];
loading: boolean;
onHoverItem: (index: number) => void;
onSelectItem: (item: TypeaheadItem) => void;
query: string;
}) {
const skillItems = items.filter((item) => item.kind === "skill");
const pluginItems = items.filter((item) => item.kind === "plugin");
const footerItems = items.filter((item) => item.kind === "footer");
const skillsFooter = footerItems.find(
(item) => item.kind === "footer" && item.section === "skills",
);
const pluginsFooter = footerItems.find(
(item) => item.kind === "footer" && item.section === "plugins",
);
const hasMatches = skillItems.length > 0 || pluginItems.length > 0;
return (
<div className="navbar-search-typeahead" id="navbar-search-typeahead" role="listbox">
<TypeaheadSection
activeIndex={activeIndex}
items={items}
label="Skills"
sectionItems={skillItems}
footer={skillsFooter}
onHoverItem={onHoverItem}
onSelectItem={onSelectItem}
/>
<TypeaheadSection
activeIndex={activeIndex}
items={items}
label="Plugins"
sectionItems={pluginItems}
footer={pluginsFooter}
onHoverItem={onHoverItem}
onSelectItem={onSelectItem}
/>
{loading && !hasMatches ? (
<div className="navbar-search-typeahead-status">Searching...</div>
) : null}
{!loading && !hasMatches ? (
<div className="navbar-search-typeahead-status">
No skills or plugins found for "{query}"
</div>
) : null}
</div>
);
}
function TypeaheadSection({
activeIndex,
footer,
items,
label,
onHoverItem,
onSelectItem,
sectionItems,
}: {
activeIndex: number;
footer: TypeaheadItem | undefined;
items: TypeaheadItem[];
label: string;
onHoverItem: (index: number) => void;
onSelectItem: (item: TypeaheadItem) => void;
sectionItems: TypeaheadItem[];
}) {
if (sectionItems.length === 0 && !footer) return null;
return (
<div className="navbar-search-typeahead-section">
<div className="navbar-search-typeahead-heading">{label}</div>
{sectionItems.map((item) => (
<TypeaheadRow
key={item.key}
active={items[activeIndex]?.key === item.key}
item={item}
index={items.findIndex((candidate) => candidate.key === item.key)}
onHoverItem={onHoverItem}
onSelectItem={onSelectItem}
/>
))}
{footer ? (
<TypeaheadRow
active={items[activeIndex]?.key === footer.key}
item={footer}
index={items.findIndex((candidate) => candidate.key === footer.key)}
onHoverItem={onHoverItem}
onSelectItem={onSelectItem}
/>
) : null}
</div>
);
}
function TypeaheadRow({
active,
index,
item,
onHoverItem,
onSelectItem,
}: {
active: boolean;
index: number;
item: TypeaheadItem;
onHoverItem: (index: number) => void;
onSelectItem: (item: TypeaheadItem) => void;
}) {
const body = getTypeaheadRowBody(item);
return (
<button
className={`navbar-search-typeahead-row${active ? " is-active" : ""}${item.kind === "footer" ? " is-footer" : ""}`}
type="button"
role="option"
aria-selected={active}
onMouseEnter={() => onHoverItem(index)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => onSelectItem(item)}
>
{body.icon ? <span className="navbar-search-typeahead-icon">{body.icon}</span> : null}
<span className="navbar-search-typeahead-copy">
<span className="navbar-search-typeahead-title">{body.title}</span>
{body.meta ? <span className="navbar-search-typeahead-meta">{body.meta}</span> : null}
</span>
{item.kind === "footer" ? <ArrowRight size={14} aria-hidden="true" /> : null}
</button>
);
}
function getTypeaheadRowBody(item: TypeaheadItem) {
if (item.kind === "skill") {
const owner = item.result.ownerHandle ? `@${item.result.ownerHandle}` : "Skill";
return {
icon: "S",
title: item.result.skill.displayName,
meta: `${owner} / ${item.result.skill.slug}`,
};
}
if (item.kind === "plugin") {
return {
icon: "P",
title: item.result.plugin.displayName,
meta: item.result.plugin.ownerHandle
? `@${item.result.plugin.ownerHandle} / ${item.result.plugin.name}`
: item.result.plugin.name,
};
}
return {
icon: null,
title: item.label,
meta: null,
};
}
function getCurrentRelativeUrl() {
if (typeof window === "undefined") return "/";
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
+10 -2
View File
@@ -1,7 +1,7 @@
import { Link } from "@tanstack/react-router";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
import { Calendar, Download, History, Package, Scale, Settings, Star } from "lucide-react";
import { Calendar, Download, History, Package, Scale, Settings, Star, Upload } from "lucide-react";
import type { ReactNode } from "react";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { getSkillBadges } from "../lib/badges";
@@ -171,7 +171,7 @@ export function SkillHeader({
<span className="plugin-version-badge">v{latestVersion.version}</span>
) : null}
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
{isStaff || settingsHref ? (
{canManage || isStaff || settingsHref ? (
<div className="skill-title-actions">
{isStaff ? (
<Button asChild variant="outline" size="sm">
@@ -180,6 +180,14 @@ export function SkillHeader({
</Link>
</Button>
) : null}
{canManage ? (
<Button asChild variant="outline" size="sm" className="skill-settings-link">
<Link to="/publish-skill" search={{ updateSlug: skill.slug }}>
<Upload size={14} aria-hidden="true" />
New Version
</Link>
</Button>
) : null}
{settingsHref ? (
<Button asChild variant="outline" size="sm" className="skill-settings-link">
<a href={settingsHref}>
+6
View File
@@ -0,0 +1,6 @@
import { fetchPluginCatalog } from "./packageApi";
export async function fetchFeaturedPlugins(limit: number = 50) {
const result = await fetchPluginCatalog({ featured: true, limit });
return result.items;
}
+18 -3
View File
@@ -255,6 +255,7 @@ export async function fetchPackages(params: {
cursor?: string;
family?: "skill" | "code-plugin" | "bundle-plugin";
isOfficial?: boolean;
featured?: boolean;
executesCode?: boolean;
capabilityTag?: string;
limit?: number;
@@ -267,6 +268,7 @@ export async function fetchPackages(params: {
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
}
if (params.featured) url.searchParams.set("featured", "true");
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
@@ -287,6 +289,7 @@ export async function fetchPackages(params: {
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
}
if (params.featured) url.searchParams.set("featured", "true");
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
@@ -299,6 +302,7 @@ export async function fetchPluginCatalog(params: {
cursor?: string;
family?: PluginFamily;
isOfficial?: boolean;
featured?: boolean;
executesCode?: boolean;
limit?: number;
}): Promise<PluginCatalogResult> {
@@ -308,6 +312,7 @@ export async function fetchPluginCatalog(params: {
cursor: params.cursor,
family: params.family,
isOfficial: params.isOfficial,
featured: params.featured,
executesCode: params.executesCode,
limit: params.limit,
});
@@ -332,6 +337,7 @@ export async function fetchPluginCatalog(params: {
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
}
if (params.featured) url.searchParams.set("featured", "true");
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
@@ -339,7 +345,9 @@ export async function fetchPluginCatalog(params: {
results?: Array<{ score: number; package: PackageListItem }>;
}>(url);
return {
items: (response?.results ?? []).map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
items: (response?.results ?? [])
.map((entry) => entry?.package)
.filter(Boolean) as PackageListItem[],
nextCursor: null,
};
}
@@ -350,6 +358,7 @@ export async function fetchPluginCatalog(params: {
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
}
if (params.featured) url.searchParams.set("featured", "true");
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
@@ -370,7 +379,10 @@ export async function fetchPackageDetail(name: string): Promise<PackageDetailRes
return (await response.json()) as PackageDetailResponse;
}
export async function fetchPackageVersion(name: string, version: string): Promise<PackageVersionDetail | null> {
export async function fetchPackageVersion(
name: string,
version: string,
): Promise<PackageVersionDetail | null> {
try {
const url = await packageApiUrl(
`${ApiRoutes.packages}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`,
@@ -382,7 +394,10 @@ export async function fetchPackageVersion(name: string, version: string): Promis
}
}
export async function fetchPackageReadme(name: string, version?: string | null): Promise<string | null> {
export async function fetchPackageReadme(
name: string,
version?: string | null,
): Promise<string | null> {
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
url.searchParams.set("path", "README.md");
if (version) url.searchParams.set("version", version);
+46 -47
View File
@@ -1,9 +1,9 @@
import { useAction } from "convex/react";
import { useEffect, useRef, useState } from "react";
import { api } from "../../convex/_generated/api";
import { convexHttp } from "../convex/client";
import { fetchPluginCatalog, type PackageListItem } from "./packageApi";
import type { PublicUser } from "./publicUser";
export type UnifiedSearchType = "all" | "skills" | "plugins";
export type UnifiedSkillResult = {
type: "skill";
@@ -27,33 +27,44 @@ export type UnifiedPluginResult = {
plugin: PackageListItem;
};
export type UnifiedUserResult = {
type: "user";
user: PublicUser;
};
export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult;
export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult | UnifiedUserResult;
type UnifiedSearchOptions = {
debounceMs?: number;
enabled?: boolean;
limits?: {
skills?: number;
plugins?: number;
};
};
export function useUnifiedSearch(
query: string,
activeType: "all" | "skills" | "plugins" | "users",
activeType: UnifiedSearchType,
options: UnifiedSearchOptions = {},
) {
const searchSkills = useAction(api.search.searchSkills);
const [results, setResults] = useState<UnifiedResult[]>([]);
const [skillResults, setSkillResults] = useState<UnifiedSkillResult[]>([]);
const [pluginResults, setPluginResults] = useState<UnifiedPluginResult[]>([]);
const [skillCount, setSkillCount] = useState(0);
const [pluginCount, setPluginCount] = useState(0);
const [userCount, setUserCount] = useState(0);
const [isSearching, setIsSearching] = useState(false);
const requestRef = useRef(0);
const debounceMs = options.debounceMs ?? 300;
const enabled = options.enabled ?? true;
const skillLimit = options.limits?.skills ?? 25;
const pluginLimit = options.limits?.plugins ?? 25;
useEffect(() => {
const trimmed = query.trim();
if (!trimmed) {
if (!enabled || !trimmed) {
requestRef.current += 1;
setResults([]);
setSkillResults([]);
setPluginResults([]);
setSkillCount(0);
setPluginCount(0);
setUserCount(0);
setIsSearching(false);
return () => {};
}
@@ -65,40 +76,34 @@ export function useUnifiedSearch(
const handle = window.setTimeout(() => {
void (async () => {
try {
const promises: [
Promise<unknown> | null,
Promise<{ items: PackageListItem[] }> | null,
Promise<{ items: PublicUser[] }> | null,
] = [null, null, null];
const promises: [Promise<unknown> | null, Promise<{ items: PackageListItem[] }> | null] =
[null, null];
if (activeType === "all" || activeType === "skills") {
promises[0] = searchSkills({
query: trimmed,
limit: 25,
limit: skillLimit,
nonSuspiciousOnly: true,
});
}
if (activeType === "all" || activeType === "plugins") {
promises[1] = fetchPluginCatalog({ q: trimmed, limit: 25 });
promises[1] = fetchPluginCatalog({ q: trimmed, limit: pluginLimit });
}
if (activeType === "all" || activeType === "users") {
promises[2] = convexHttp.query(api.users.listPublic, { search: trimmed, limit: 25 });
}
const settled = await Promise.allSettled(
promises.map((p) => p ?? Promise.resolve(null)),
);
const settled = await Promise.allSettled(promises.map((p) => p ?? Promise.resolve(null)));
if (requestId !== requestRef.current) return;
const skillsRaw = settled[0].status === "fulfilled" ? settled[0].value : null;
const pluginsRaw = settled[1].status === "fulfilled" ? settled[1].value : null;
const usersRaw = settled[2].status === "fulfilled" ? settled[2].value : null;
const skillResults: UnifiedSkillResult[] = (
(skillsRaw as Array<{ skill: UnifiedSkillResult["skill"]; ownerHandle: string | null; score: number }>) ?? []
const nextSkillResults: UnifiedSkillResult[] = (
(skillsRaw as Array<{
skill: UnifiedSkillResult["skill"];
ownerHandle: string | null;
score: number;
}>) ?? []
).map((entry) => ({
type: "skill" as const,
skill: entry.skill,
@@ -106,32 +111,25 @@ export function useUnifiedSearch(
score: entry.score,
}));
const pluginResults: UnifiedPluginResult[] = (
const nextPluginResults: UnifiedPluginResult[] = (
(pluginsRaw as { items: PackageListItem[] })?.items ?? []
).map((item) => ({
type: "plugin" as const,
plugin: item,
}));
setSkillCount(skillResults.length);
setPluginCount(pluginResults.length);
const userResults: UnifiedUserResult[] = (
(usersRaw as { items: PublicUser[] })?.items ?? []
).map((user) => ({
type: "user" as const,
user,
}));
setUserCount(userResults.length);
setSkillCount(nextSkillResults.length);
setPluginCount(nextPluginResults.length);
setSkillResults(nextSkillResults);
setPluginResults(nextPluginResults);
const merged: UnifiedResult[] = [];
if (activeType === "all") {
merged.push(...skillResults, ...pluginResults, ...userResults);
merged.push(...nextSkillResults, ...nextPluginResults);
} else if (activeType === "skills") {
merged.push(...skillResults);
} else if (activeType === "plugins") {
merged.push(...pluginResults);
merged.push(...nextSkillResults);
} else {
merged.push(...userResults);
merged.push(...nextPluginResults);
}
setResults(merged);
@@ -139,9 +137,10 @@ export function useUnifiedSearch(
console.error("Unified search failed:", error);
if (requestId === requestRef.current) {
setResults([]);
setSkillResults([]);
setPluginResults([]);
setSkillCount(0);
setPluginCount(0);
setUserCount(0);
}
} finally {
if (requestId === requestRef.current) {
@@ -149,10 +148,10 @@ export function useUnifiedSearch(
}
}
})();
}, 300);
}, debounceMs);
return () => window.clearTimeout(handle);
}, [query, activeType, searchSkills]);
}, [query, activeType, searchSkills, debounceMs, enabled, skillLimit, pluginLimit]);
return { results, skillCount, pluginCount, userCount, isSearching };
return { results, skillResults, pluginResults, skillCount, pluginCount, isSearching };
}
+37
View File
@@ -1,6 +1,8 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { api } from "../../convex/_generated/api";
import { Settings } from "./settings";
const useQueryMock = vi.fn();
@@ -16,6 +18,15 @@ vi.mock("@convex-dev/auth/react", () => ({
useAuthActions: () => useAuthActionsMock(),
}));
vi.mock("@tanstack/react-router", async () => {
const actual =
await vi.importActual<typeof import("@tanstack/react-router")>("@tanstack/react-router");
return {
...actual,
Link: ({ children, to }: { children: ReactNode; to: string }) => <a href={to}>{children}</a>,
};
});
describe("Settings", () => {
beforeEach(() => {
useQueryMock.mockReset();
@@ -35,4 +46,30 @@ describe("Settings", () => {
expect(screen.getByText(/sign in to access settings\./i)).toBeTruthy();
expect(useQueryMock.mock.calls.some(([, args]) => args === "skip")).toBe(true);
});
it("links to starred skills from signed-in settings", () => {
useQueryMock.mockImplementation((query, args) => {
if (query === api.users.me) {
return {
_id: "user_123",
displayName: "Patrick",
name: "Patrick",
handle: "patrick",
email: "patrick@example.com",
image: null,
bio: null,
};
}
if (args === "skip") return undefined;
if (args && typeof args === "object" && "publisherHandle" in args) {
return undefined;
}
return [];
});
render(<Settings />);
expect(screen.getByRole("heading", { name: "Stars" })).toBeTruthy();
expect(screen.getByRole("link", { name: "View stars" }).getAttribute("href")).toBe("/stars");
});
});
+63 -364
View File
@@ -1,6 +1,5 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useAction, useQuery } from "convex/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
ArrowRight,
@@ -12,10 +11,13 @@ import {
Star,
Users,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { api } from "../../convex/_generated/api";
import { SoulCard } from "../components/SoulCard";
import { SoulStatsTripletLine } from "../components/SoulStats";
import { convexHttp } from "../convex/client";
import { fetchFeaturedPlugins } from "../lib/featuredCatalog";
import type { PackageListItem } from "../lib/packageApi";
import type { PublicSkill, PublicSoul, PublicUser } from "../lib/publicUser";
import { getSiteMode } from "../lib/site";
@@ -28,13 +30,6 @@ function Home() {
return mode === "souls" ? <OnlyCrabsHome /> : <SkillsHome />;
}
// ═══ Slot machine word pool (13 words = 1/13 jackpot odds) ═══
const SLOT_WORDS = [
"Equip", "Install", "Unleash", "Ship", "Build",
"Create", "Deploy", "Launch", "Hack", "Scale",
"Forge", "Craft", "Wield",
];
function SkillsHome() {
type SkillPageEntry = {
skill: PublicSkill;
@@ -44,7 +39,7 @@ function SkillsHome() {
};
const [highlighted, setHighlighted] = useState<SkillPageEntry[]>([]);
const [popular, setPopular] = useState<SkillPageEntry[]>([]);
const [featuredPlugins, setFeaturedPlugins] = useState<PackageListItem[]>([]);
const [query, setQuery] = useState("");
const navigate = useNavigate();
@@ -56,15 +51,9 @@ function SkillsHome() {
if (!cancelled) setHighlighted(r as SkillPageEntry[]);
})
.catch(() => {});
convexHttp
.query(api.skills.listPublicPageV4, {
numItems: 12,
sort: "downloads",
dir: "desc",
nonSuspiciousOnly: true,
})
.then((r) => {
if (!cancelled) setPopular((r as { page: SkillPageEntry[] }).page);
fetchFeaturedPlugins(6)
.then((items) => {
if (!cancelled) setFeaturedPlugins(items);
})
.catch(() => {});
return () => {
@@ -104,262 +93,8 @@ function SkillsHome() {
// Build carousel cards from highlighted data
const carouselCards = highlighted.length > 0 ? highlighted.slice(0, 6) : [];
// ═══ SLOT MACHINE EASTER EGG ═══
const HACK_INDEX = SLOT_WORDS.indexOf("Hack");
const clickTimesRef = useRef<number[]>([]);
const [slotState, setSlotState] = useState<
| null
| { phase: "spinning" }
| { phase: "stopped"; results: [number, number, number]; won: boolean; isHackJackpot: boolean }
>(null);
const slotTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [slotReelOffsets, setSlotReelOffsets] = useState<[number, number, number]>([0, 0, 0]);
const [stoppedReels, setStoppedReels] = useState<Set<number>>(new Set());
const confettiRef = useRef<HTMLCanvasElement>(null);
const spinIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const cooldownUntilRef = useRef<number>(0);
// Clean up timers/intervals if the component unmounts mid-spin
useEffect(() => {
return () => {
for (const t of slotTimersRef.current) clearTimeout(t);
if (spinIntervalRef.current) clearInterval(spinIntervalRef.current);
};
}, []);
const triggerSlots = useCallback(() => {
// Clean up any previous timers
for (const t of slotTimersRef.current) clearTimeout(t);
slotTimersRef.current = [];
if (spinIntervalRef.current) clearInterval(spinIntervalRef.current);
setSlotState({ phase: "spinning" });
setStoppedReels(new Set());
// Controlled odds: ~1/25 any jackpot, ~1/100 Hack jackpot
let r0: number, r1: number, r2: number;
const isJackpot = Math.random() < 1 / 25;
if (isJackpot) {
// 25% of jackpots are Hack (1/25 × 1/4 = 1/100 overall)
const isHack = Math.random() < 0.25;
if (isHack) {
r0 = HACK_INDEX;
} else {
// Pick any word except Hack
let idx = Math.floor(Math.random() * (SLOT_WORDS.length - 1));
if (idx >= HACK_INDEX) idx++;
r0 = idx;
}
r1 = r0;
r2 = r0;
} else {
// Normal spin — re-roll if accidental triple match
do {
r0 = Math.floor(Math.random() * SLOT_WORDS.length);
r1 = Math.floor(Math.random() * SLOT_WORDS.length);
r2 = Math.floor(Math.random() * SLOT_WORDS.length);
} while (r0 === r1 && r1 === r2);
}
const results: [number, number, number] = [r0, r1, r2];
const landed = new Set<number>();
// Animate fast offset cycling — only cycle reels that haven't landed
let frame = 0;
const spinInterval = setInterval(() => {
frame++;
setSlotReelOffsets((prev) => [
landed.has(0) ? prev[0] : (frame * 3) % SLOT_WORDS.length,
landed.has(1) ? prev[1] : (frame * 5 + 4) % SLOT_WORDS.length,
landed.has(2) ? prev[2] : (frame * 7 + 9) % SLOT_WORDS.length,
]);
}, 60);
spinIntervalRef.current = spinInterval;
// Stop reels sequentially with a satisfying stagger
const stopReel = (reelIdx: 0 | 1 | 2, delay: number) => {
const t = setTimeout(() => {
landed.add(reelIdx);
setStoppedReels((prev) => new Set(prev).add(reelIdx));
setSlotReelOffsets((prev) => {
const next = [...prev] as [number, number, number];
next[reelIdx] = results[reelIdx];
return next;
});
}, delay);
slotTimersRef.current.push(t);
};
stopReel(0, 1200);
stopReel(1, 1800);
const tFinal = setTimeout(() => {
clearInterval(spinInterval);
spinIntervalRef.current = null;
landed.add(2);
setStoppedReels(new Set([0, 1, 2]));
setSlotReelOffsets(results);
const won = r0 === r1 && r1 === r2;
const isHackJackpot = won && r0 === HACK_INDEX;
setSlotState({ phase: "stopped", results, won, isHackJackpot });
if (won) {
fireConfetti(isHackJackpot);
}
// Cooldown: 18s after win, 3s after loss
const displayTime = won ? 10000 : 2400;
const cooldownTime = won ? 18000 : 3000;
cooldownUntilRef.current = Date.now() + cooldownTime;
const tReset = setTimeout(() => {
setSlotState(null);
setStoppedReels(new Set());
}, displayTime);
slotTimersRef.current.push(tReset);
}, 2400);
slotTimersRef.current.push(tFinal);
}, []);
const handleLabelClick = useCallback(() => {
const now = Date.now();
// Respect cooldown period
if (now < cooldownUntilRef.current) return;
clickTimesRef.current.push(now);
// Keep only last 3 clicks
if (clickTimesRef.current.length > 3) {
clickTimesRef.current = clickTimesRef.current.slice(-3);
}
if (clickTimesRef.current.length === 3) {
const first = clickTimesRef.current[0];
const last = clickTimesRef.current[2];
if (last - first < 800 && !slotState) {
clickTimesRef.current = [];
triggerSlots();
}
}
}, [slotState, triggerSlots]);
const fireConfetti = (isHackJackpot: boolean) => {
const canvas = confettiRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
canvas.style.display = "block";
const STANDARD_COLORS = [
"#d4453a", "#ff6b6b", "#ffd93d", "#6bcb77",
"#4d96ff", "#ff6f91", "#845ec2", "#ffc75f",
];
const OCEAN_COLORS = [
"#0ea5e9", "#06b6d4", "#14b8a6", "#22d3ee",
"#38bdf8", "#67e8f9", "#a5f3fc", "#2dd4bf",
"#d4453a", "#ff6b6b",
];
const colors = isHackJackpot ? OCEAN_COLORS : STANDARD_COLORS;
type Particle = {
x: number; y: number; vx: number; vy: number;
w: number; h: number; color: string; rot: number; vr: number;
life: number; shape: "rect" | "bubble" | "claw";
};
const particles: Particle[] = [];
const count = isHackJackpot ? 200 : 150;
for (let i = 0; i < count; i++) {
const isBubble = isHackJackpot && Math.random() < 0.35;
const isClaw = isHackJackpot && !isBubble && Math.random() < 0.2;
particles.push({
x: canvas.width / 2 + (Math.random() - 0.5) * 300,
y: canvas.height * 0.35,
vx: (Math.random() - 0.5) * 18,
vy: isHackJackpot
? -Math.random() * 14 - 2 + (isBubble ? -4 : 0)
: -Math.random() * 16 - 4,
w: isBubble ? Math.random() * 8 + 4 : Math.random() * 10 + 4,
h: isBubble ? 0 : Math.random() * 6 + 3,
color: colors[Math.floor(Math.random() * colors.length)],
rot: Math.random() * Math.PI * 2,
vr: (Math.random() - 0.5) * 0.3,
life: isHackJackpot ? 1.3 : 1,
shape: isClaw ? "claw" : isBubble ? "bubble" : "rect",
});
}
const drawClaw = (context: CanvasRenderingContext2D, size: number) => {
// Simple lobster claw shape
context.beginPath();
context.moveTo(0, size * 0.5);
context.quadraticCurveTo(-size * 0.6, size * 0.2, -size * 0.4, -size * 0.3);
context.quadraticCurveTo(-size * 0.2, -size * 0.6, 0, -size * 0.3);
context.quadraticCurveTo(size * 0.2, -size * 0.6, size * 0.4, -size * 0.3);
context.quadraticCurveTo(size * 0.6, size * 0.2, 0, size * 0.5);
context.closePath();
context.fill();
};
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
let alive = false;
for (const p of particles) {
if (p.life <= 0) continue;
alive = true;
p.x += p.vx;
p.y += p.vy;
p.vy += p.shape === "bubble" ? 0.15 : 0.4;
p.vx *= 0.99;
p.rot += p.vr;
p.life -= isHackJackpot ? 0.005 : 0.008;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.globalAlpha = Math.max(0, Math.min(1, p.life));
ctx.fillStyle = p.color;
if (p.shape === "bubble") {
ctx.beginPath();
ctx.arc(0, 0, p.w, 0, Math.PI * 2);
ctx.strokeStyle = p.color;
ctx.lineWidth = 1.5;
ctx.globalAlpha *= 0.7;
ctx.stroke();
ctx.globalAlpha *= 0.15;
ctx.fill();
} else if (p.shape === "claw") {
drawClaw(ctx, p.w);
} else {
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
}
ctx.restore();
}
if (alive) {
requestAnimationFrame(draw);
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.style.display = "none";
}
};
requestAnimationFrame(draw);
};
const renderSlotReel = (reelIdx: 0 | 1 | 2) => {
const offset = slotReelOffsets[reelIdx];
const word = SLOT_WORDS[offset];
const isReelSpinning = slotState !== null && !stoppedReels.has(reelIdx);
return (
<span className={`home-v2-slot-reel ${isReelSpinning ? "spinning" : ""}`}>
<span className="home-v2-slot-word">{word}</span>
</span>
);
};
return (
<main className="home-v2-main">
{/* Confetti canvas for slot machine wins */}
<canvas
ref={confettiRef}
className="home-v2-confetti"
style={{ display: "none" }}
/>
{/* ═══ HERO ═══ */}
<section className="home-v2-hero">
<div className="home-v2-hero-bg">
@@ -370,81 +105,41 @@ function SkillsHome() {
<div className="home-v2-ring home-v2-ring-3" />
</div>
<div
className={`home-v2-hero-label ${slotState ? "home-v2-hero-label-active" : ""}`}
onClick={handleLabelClick}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter") handleLabelClick(); }}
>
BUILT BY THE COMMUNITY.
</div>
{slotState ? (
<h1 className={`home-v2-headline home-v2-headline-slots${
slotState.phase === "stopped" && slotState.won
? slotState.isHackJackpot
? " home-v2-headline-jackpot home-v2-headline-hack"
: " home-v2-headline-jackpot"
: ""
}`}>
{slotState.phase === "stopped" && slotState.isHackJackpot && (
<img
src="/clawd-mark.png"
alt=""
aria-hidden="true"
className="home-v2-hack-lobster"
/>
)}
<span className="home-v2-headline-inner">
{renderSlotReel(0)}
<span className="home-v2-sep" />
{renderSlotReel(1)}
<span className="home-v2-sep" />
{renderSlotReel(2)}
</span>
</h1>
) : (
<h1 className="home-v2-headline">
<span className="home-v2-headline-inner">
<span className="home-v2-action-word">Equip</span>
<span className="home-v2-sep" />
<span className="home-v2-action-word">Install</span>
<span className="home-v2-sep" />
<span className="home-v2-cycle-wrap">
<span className="home-v2-cycle-track">
<span className="home-v2-cycle-word">Unleash.</span>
<span className="home-v2-cycle-word">Ship.</span>
<span className="home-v2-cycle-word">Build.</span>
<span className="home-v2-cycle-word">Create.</span>
<span className="home-v2-cycle-word">Unleash.</span>
</span>
<h1 className="home-v2-headline">
<span className="home-v2-headline-inner">
<span className="home-v2-action-word">Equip</span>
<span className="home-v2-sep" />
<span className="home-v2-action-word">Install</span>
<span className="home-v2-sep" />
<span className="home-v2-cycle-wrap">
<span className="home-v2-cycle-track">
<span className="home-v2-cycle-word">Unleash.</span>
<span className="home-v2-cycle-word">Ship.</span>
<span className="home-v2-cycle-word">Build.</span>
<span className="home-v2-cycle-word">Create.</span>
<span className="home-v2-cycle-word">Unleash.</span>
</span>
</span>
</h1>
)}
<p className="home-v2-sub">Tools built by thousands, ready in one search.</p>
</span>
</h1>
<div className="home-v2-search-container">
<form className="home-v2-search-bar" onSubmit={handleSearch}>
<Search className="home-v2-search-icon" size={20} />
<input
autoFocus
type="text"
placeholder="What are you looking for?"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<kbd>/</kbd>
<button type="submit" className="home-v2-search-go">
<span className="home-v2-search-go-label">Search</span>{" "}
<ArrowRight size={16} />
<span className="home-v2-search-go-label">Search</span> <ArrowRight size={16} />
</button>
</form>
</div>
<div className="home-v2-suggestions">
<span className="home-v2-suggestions-label">Try</span>
<button
type="button"
className="home-v2-suggestion"
@@ -480,8 +175,24 @@ function SkillsHome() {
{carouselCards.length > 0 && (
<section className="home-v2-carousel-section">
<div className="home-v2-carousel-header">
<h2>Featured</h2>
<h2>Featured skills</h2>
<div className="home-v2-carousel-controls">
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
featured: true,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
className="home-v2-section-link"
>
View all <ArrowRight size={14} />
</Link>
<button type="button" className="home-v2-carousel-btn" aria-label="Previous">
<ArrowLeft size={16} />
</button>
@@ -516,12 +227,10 @@ function SkillsHome() {
<div className="home-v2-c-footer">
<div className="home-v2-c-stats">
<span>
<Star size={12} />{" "}
{formatStat(entry.skill.stats?.stars)}
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
</span>
<span>
<Download size={12} />{" "}
{formatStat(entry.skill.stats?.downloads)}
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
</span>
</div>
<span className="home-v2-c-install">
@@ -554,12 +263,10 @@ function SkillsHome() {
<div className="home-v2-c-footer">
<div className="home-v2-c-stats">
<span>
<Star size={12} />{" "}
{formatStat(entry.skill.stats?.stars)}
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
</span>
<span>
<Download size={12} />{" "}
{formatStat(entry.skill.stats?.downloads)}
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
</span>
</div>
<span className="home-v2-c-install">
@@ -650,21 +357,20 @@ function SkillsHome() {
</div>
</div>
{/* ═══ TRENDING ═══ */}
{popular.length > 0 && (
{/* ═══ FEATURED PLUGINS ═══ */}
{featuredPlugins.length > 0 && (
<section className="home-v2-trending-section">
<div className="home-v2-section-header">
<h2>Trending Now</h2>
<h2>Featured plugins</h2>
<Link
to="/skills"
to="/plugins"
search={{
q: undefined,
sort: "downloads",
dir: "desc",
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
cursor: undefined,
family: undefined,
featured: true,
verified: undefined,
executesCode: undefined,
}}
className="home-v2-section-link"
>
@@ -672,33 +378,26 @@ function SkillsHome() {
</Link>
</div>
<div className="home-v2-trending-grid">
{popular.slice(0, 6).map((entry) => (
{featuredPlugins.slice(0, 6).map((plugin) => (
<Link
key={entry.skill._id}
to={skillLink(entry)}
key={plugin.name}
to="/plugins/$name"
params={{ name: plugin.name }}
className="home-v2-trend-card"
>
<div className="home-v2-trend-head">
<div className="home-v2-trend-title">
{entry.skill.displayName || entry.skill.slug}
</div>
<div className="home-v2-trend-title">{plugin.displayName || plugin.name}</div>
<div className="home-v2-trend-creator">
by {entry.ownerHandle || entry.owner?.handle || "unknown"}
{plugin.ownerHandle ? `by @${plugin.ownerHandle}` : "community plugin"}
</div>
</div>
<div className="home-v2-trend-desc">
{entry.skill.summary || "Agent-ready skill pack."}
{plugin.summary || "Gateway plugin for OpenClaw workflows."}
</div>
<div className="home-v2-trend-bottom">
<div className="home-v2-trend-signals">
<span>
<Star size={12} />{" "}
{formatStat(entry.skill.stats?.stars)}
</span>
<span>
<Download size={12} />{" "}
{formatStat(entry.skill.stats?.downloads)}
</span>
{plugin.isOfficial ? <span>Verified</span> : null}
{plugin.latestVersion ? <span>v{plugin.latestVersion}</span> : null}
</div>
<span className="home-v2-trend-install">
<Download size={13} /> Install
+151 -35
View File
@@ -1,4 +1,4 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery } from "convex/react";
import { useEffect, useState } from "react";
import { api } from "../../convex/_generated/api";
@@ -12,6 +12,8 @@ import {
isSkillHighlighted,
isSkillOfficial,
} from "../lib/badges";
import { familyLabel } from "../lib/packageLabels";
import type { PublicPublisher } from "../lib/publicUser";
import { isAdmin, isModerator } from "../lib/roles";
import { useAuthStatus } from "../lib/useAuthStatus";
@@ -75,6 +77,13 @@ type SkillBySlugResult = {
} | null;
} | null;
type PluginByNameResult = {
package: Doc<"packages">;
latestRelease: Doc<"packageReleases"> | null;
owner: PublicPublisher | null;
highlighted: { byUserId: Id<"users">; at: number } | null;
} | null;
function resolveOwnerParam(
handle: string | null | undefined,
ownerId?: Id<"users"> | Id<"publishers">,
@@ -99,6 +108,7 @@ function promptUnbanReason(label: string) {
export const Route = createFileRoute("/management")({
validateSearch: (search) => ({
skill: typeof search.skill === "string" && search.skill.trim() ? search.skill : undefined,
plugin: typeof search.plugin === "string" && search.plugin.trim() ? search.plugin : undefined,
}),
component: Management,
});
@@ -106,14 +116,20 @@ export const Route = createFileRoute("/management")({
function Management() {
const { me } = useAuthStatus();
const search = Route.useSearch();
const navigate = useNavigate();
const staff = isModerator(me);
const admin = isAdmin(me);
const selectedSlug = search.skill?.trim();
const selectedPluginName = search.plugin?.trim();
const selectedSkill = useQuery(
api.skills.getBySlugForStaff,
staff && selectedSlug ? { slug: selectedSlug, auditLogLimit: SKILL_AUDIT_LOG_LIMIT } : "skip",
) as SkillBySlugResult | undefined;
const selectedPlugin = useQuery(
api.packages.getByNameForStaff,
staff && selectedPluginName ? { name: selectedPluginName } : "skip",
) as PluginByNameResult | undefined;
const selectedSkillId = selectedSkill?.skill?._id ?? null;
const recentVersions = useQuery(api.skills.listRecentVersions, staff ? { limit: 20 } : "skip") as
| RecentVersionEntry[]
@@ -130,6 +146,7 @@ function Management() {
const banUser = useMutation(api.users.banUser);
const unbanUser = useMutation(api.users.unbanUser);
const setBatch = useMutation(api.skills.setBatch);
const setPackageBatch = useMutation(api.packages.setBatch);
const setSoftDeleted = useMutation(api.skills.setSoftDeleted);
const hardDelete = useMutation(api.skills.hardDelete);
const changeOwner = useMutation(api.skills.changeOwner);
@@ -145,6 +162,7 @@ function Management() {
const [reportSearchDebounced, setReportSearchDebounced] = useState("");
const [userSearch, setUserSearch] = useState("");
const [userSearchDebounced, setUserSearchDebounced] = useState("");
const [pluginSearch, setPluginSearch] = useState(selectedPluginName ?? "");
const [skillOverrideNote, setSkillOverrideNote] = useState("");
const userQuery = userSearchDebounced.trim();
@@ -166,6 +184,10 @@ function Management() {
setSkillOverrideNote("");
}, [selectedSkillId]);
useEffect(() => {
setPluginSearch(selectedPluginName ?? "");
}, [selectedPluginName]);
useEffect(() => {
const handle = setTimeout(() => setReportSearchDebounced(reportSearch), 250);
return () => clearTimeout(handle);
@@ -257,15 +279,22 @@ function Management() {
.catch((error) => window.alert(formatMutationError(error)));
};
const managePlugin = () => {
const name = pluginSearch.trim();
if (!name) return;
void navigate({
to: "/management",
search: { skill: undefined, plugin: name },
});
};
return (
<main className="section">
<h1 className="section-title">Management console</h1>
<p className="section-subtitle">Moderation, curation, and ownership tools.</p>
<Card>
<h2 className="section-title text-[1.2rem] m-0">
Reported skills
</h2>
<h2 className="section-title text-[1.2rem] m-0">Reported skills</h2>
<div className="management-controls">
<div className="management-control management-search">
<span className="mono">Filter</span>
@@ -318,9 +347,7 @@ function Management() {
))}
</div>
) : (
<div className="section-subtitle m-0">
No report reasons yet.
</div>
<div className="section-subtitle m-0">No report reasons yet.</div>
)}
</div>
<div className="management-actions">
@@ -365,9 +392,7 @@ function Management() {
</Card>
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">
Skill tools
</h2>
<h2 className="section-title text-[1.2rem] m-0">Skill tools</h2>
{selectedSlug ? (
<div className="section-subtitle mt-2">
Managing "{selectedSlug}" ·{" "}
@@ -417,16 +442,12 @@ function Management() {
{skill.moderationFlags?.length ? (
<div className="management-tags">
{skill.moderationFlags.map((flag: string) => (
<Badge key={flag}>
{flag}
</Badge>
<Badge key={flag}>{flag}</Badge>
))}
</div>
) : null}
<div className="management-sublist">
<div className="section-subtitle m-0">
Manual overrides
</div>
<div className="section-subtitle m-0">Manual overrides</div>
<section className="management-override-panel">
<div className="management-report-item">
<span className="management-report-meta">Current override</span>
@@ -478,18 +499,14 @@ function Management() {
</section>
</div>
<div className="management-sublist">
<div className="section-subtitle m-0">
Recent audit activity
</div>
<div className="section-subtitle m-0">Recent audit activity</div>
<section className="management-override-panel management-audit-panel">
<div className="management-report-item">
<span className="management-report-meta">Window</span>
<span>Last {SKILL_AUDIT_LOG_LIMIT} entries for this skill.</span>
</div>
{auditLogs.length === 0 ? (
<div className="section-subtitle m-0">
No audit activity yet.
</div>
<div className="section-subtitle m-0">No audit activity yet.</div>
) : (
<div className="management-audit-list">
{auditLogs.map((entry) => {
@@ -590,10 +607,7 @@ function Management() {
</div>
<div className="management-actions management-action-grid">
<Button asChild className="management-action-btn">
<Link
to="/$owner/$slug"
params={{ owner: ownerParam, slug: skill.slug }}
>
<Link to="/$owner/$slug" params={{ owner: ownerParam, slug: skill.slug }}>
View
</Link>
</Button>
@@ -693,9 +707,115 @@ function Management() {
</Card>
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">
Duplicate candidates
</h2>
<h2 className="section-title text-[1.2rem] m-0">Plugin tools</h2>
<div className="management-controls">
<div className="management-control management-search">
<span className="mono">Package</span>
<input
type="search"
placeholder="@scope/plugin-name or package-name"
value={pluginSearch}
onChange={(event) => setPluginSearch(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
managePlugin();
}
}}
/>
</div>
<Button type="button" onClick={managePlugin} disabled={!pluginSearch.trim()}>
Manage
</Button>
</div>
{selectedPluginName ? (
<div className="section-subtitle mt-2">
Managing "{selectedPluginName}" ·{" "}
<Link to="/management" search={{ skill: undefined, plugin: undefined }}>
Clear selection
</Link>
</div>
) : null}
<div className="management-list">
{!selectedPluginName ? (
<div className="stat">Enter a plugin package name to open tooling here.</div>
) : selectedPlugin === undefined ? (
<div className="stat">Loading plugin</div>
) : !selectedPlugin?.package ? (
<div className="stat">No plugin found for "{selectedPluginName}".</div>
) : (
(() => {
const plugin = selectedPlugin.package;
const owner = selectedPlugin.owner;
const latestRelease = selectedPlugin.latestRelease;
const isHighlighted = Boolean(selectedPlugin.highlighted);
return (
<div key={plugin._id} className="management-item management-item-detail">
<div className="management-item-main">
<Link to="/plugins/$name" params={{ name: plugin.name }}>
{plugin.displayName}
</Link>
<div className="section-subtitle m-0">
{owner?.handle ? `@${owner.handle}` : "unknown owner"} ·{" "}
{familyLabel(plugin.family)} · v{latestRelease?.version ?? "—"} · updated{" "}
{formatTimestamp(plugin.updatedAt)}
{plugin.softDeletedAt ? " · hidden" : ""}
{isHighlighted ? " · highlighted" : ""}
</div>
<div className="management-tags">
<Badge>{plugin.channel}</Badge>
{plugin.isOfficial ? <Badge>official</Badge> : null}
{plugin.executesCode ? <Badge>executes code</Badge> : null}
{plugin.runtimeId ? <Badge>{plugin.runtimeId}</Badge> : null}
</div>
<div className="management-sublist">
<div className="management-report-item">
<span className="management-report-meta">Package name</span>
<span className="mono">{plugin.name}</span>
</div>
<div className="management-report-item">
<span className="management-report-meta">Summary</span>
<span>{plugin.summary ?? "No summary provided."}</span>
</div>
<div className="management-report-item">
<span className="management-report-meta">Featured state</span>
<span>
{isHighlighted
? `Highlighted ${formatTimestamp(selectedPlugin.highlighted?.at ?? 0)}`
: "Not highlighted"}
</span>
</div>
</div>
</div>
<div className="management-actions management-action-grid">
<Button asChild className="management-action-btn">
<Link to="/plugins/$name" params={{ name: plugin.name }}>
View
</Link>
</Button>
<Button
className="management-action-btn"
type="button"
onClick={() =>
void setPackageBatch({
packageId: plugin._id,
batch: isHighlighted ? undefined : "highlighted",
}).catch((error) => window.alert(formatMutationError(error)))
}
>
{isHighlighted ? "Unhighlight" : "Highlight"}
</Button>
</div>
</div>
);
})()
)}
</div>
</Card>
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">Duplicate candidates</h2>
<div className="management-list">
{duplicateCandidates.length === 0 ? (
<div className="stat">No duplicate candidates.</div>
@@ -784,9 +904,7 @@ function Management() {
</Card>
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">
Recent pushes
</h2>
<h2 className="section-title text-[1.2rem] m-0">Recent pushes</h2>
<div className="management-list">
{recentVersions.length === 0 ? (
<div className="stat">No recent versions.</div>
@@ -832,9 +950,7 @@ function Management() {
{admin ? (
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">
Users
</h2>
<h2 className="section-title text-[1.2rem] m-0">Users</h2>
<div className="management-controls">
<div className="management-control management-search">
<span className="mono">Filter</span>
+185 -186
View File
@@ -400,195 +400,194 @@ function PluginDetailRoute() {
) : null}
{/* Capabilities */}
{capEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Capabilities</CardTitle>
<InstallCopyButton
text={JSON.stringify(capabilities, null, 2)}
ariaLabel="Copy capabilities JSON"
/>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{capEntries.map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{CAPABILITY_LABELS[key] ?? key}
</dt>
<dd className="min-w-0 break-words text-[color:var(--ink)]">
{key === "capabilityTags" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((tag) => (
<Link key={tag} to="/plugins" search={{ q: tag }}>
<Badge variant="compact">{tag}</Badge>
</Link>
))}
</div>
) : key === "hostTargets" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((target) => (
<Badge key={target} variant="compact">
{target}
</Badge>
))}
</div>
) : (
formatCapabilityValue(value)
)}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{capEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Capabilities</CardTitle>
<InstallCopyButton
text={JSON.stringify(capabilities, null, 2)}
ariaLabel="Copy capabilities JSON"
/>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{capEntries.map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{CAPABILITY_LABELS[key] ?? key}
</dt>
<dd className="min-w-0 break-words text-[color:var(--ink)]">
{key === "capabilityTags" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((tag) => (
<Link key={tag} to="/plugins" search={{ q: tag }}>
<Badge variant="compact">{tag}</Badge>
</Link>
))}
</div>
) : key === "hostTargets" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((target) => (
<Badge key={target} variant="compact">
{target}
</Badge>
))}
</div>
) : (
formatCapabilityValue(value)
)}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Compatibility */}
{compatEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Compatibility</CardTitle>
<InstallCopyButton
text={JSON.stringify(compatibility, null, 2)}
ariaLabel="Copy compatibility JSON"
/>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{compatEntries.map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{value}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Compatibility */}
{compatEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Compatibility</CardTitle>
<InstallCopyButton
text={JSON.stringify(compatibility, null, 2)}
ariaLabel="Copy compatibility JSON"
/>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{compatEntries.map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{value}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Verification */}
{verification && !isEmptyObject(verification) ? (
<Card>
<CardHeader>
<CardTitle>Verification</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{verification.tier ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tier</dt>
<dd className="text-[color:var(--ink)]">
{verification.tier.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.scope ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scope</dt>
<dd className="text-[color:var(--ink)]">
{verification.scope.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.summary ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Summary</dt>
<dd className="text-[color:var(--ink)]">{verification.summary}</dd>
</div>
) : null}
{verification.sourceRepo
? (() => {
const raw = verification.sourceRepo;
const href = /^https?:\/\//.test(raw) ? raw : `https://github.com/${raw}`;
const display = href.replace(/^https?:\/\//, "");
return (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Source</dt>
<dd className="text-[color:var(--ink)]">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex max-w-full flex-wrap items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
>
{display}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</dd>
</div>
);
})()
: null}
{verification.sourceCommit ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{verification.sourceCommit.slice(0, 12)}
</dd>
</div>
) : null}
{verification.sourceTag ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{verification.sourceTag}
</dd>
</div>
) : null}
{verification.hasProvenance !== undefined ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Provenance</dt>
<dd className="text-[color:var(--ink)]">
{verification.hasProvenance ? "Yes" : "No"}
</dd>
</div>
) : null}
{verification.scanStatus ? (
<div className="flex flex-col gap-1.5 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scan status</dt>
<dd className="text-[color:var(--ink)]">{verification.scanStatus}</dd>
</div>
) : null}
</dl>
</CardContent>
</Card>
) : null}
{/* Tags */}
{pkg.tags && Object.keys(pkg.tags).length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{Object.entries(pkg.tags).map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{value}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Verification */}
{verification && !isEmptyObject(verification) ? (
<Card>
<CardHeader>
<CardTitle>Verification</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{verification.tier ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tier</dt>
<dd className="text-[color:var(--ink)]">
{verification.tier.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.scope ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scope</dt>
<dd className="text-[color:var(--ink)]">
{verification.scope.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.summary ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Summary</dt>
<dd className="text-[color:var(--ink)]">{verification.summary}</dd>
</div>
) : null}
{verification.sourceRepo
? (() => {
const raw = verification.sourceRepo;
const href = /^https?:\/\//.test(raw) ? raw : `https://github.com/${raw}`;
const display = href.replace(/^https?:\/\//, "");
return (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Source</dt>
<dd className="text-[color:var(--ink)]">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex max-w-full flex-wrap items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
>
{display}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</dd>
</div>
);
})()
: null}
{verification.sourceCommit ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{verification.sourceCommit.slice(0, 12)}
</dd>
</div>
) : null}
{verification.sourceTag ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{verification.sourceTag}
</dd>
</div>
) : null}
{verification.hasProvenance !== undefined ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Provenance</dt>
<dd className="text-[color:var(--ink)]">
{verification.hasProvenance ? "Yes" : "No"}
</dd>
</div>
) : null}
{verification.scanStatus ? (
<div className="flex flex-col gap-1.5 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scan status</dt>
<dd className="text-[color:var(--ink)]">{verification.scanStatus}</dd>
</div>
) : null}
</dl>
</CardContent>
</Card>
) : null}
{/* Tags */}
{pkg.tags && Object.keys(pkg.tags).length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{Object.entries(pkg.tags).map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{value}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
</DetailHero>
</DetailPageShell>
</main>
+26 -11
View File
@@ -14,6 +14,7 @@ type PluginSearchState = {
q?: string;
cursor?: string;
family?: "code-plugin" | "bundle-plugin";
featured?: boolean;
verified?: boolean;
executesCode?: boolean;
};
@@ -43,14 +44,16 @@ export const Route = createFileRoute("/plugins/")({
search.family === "code-plugin" || search.family === "bundle-plugin"
? search.family
: undefined,
featured:
search.featured === true || search.featured === "true" || search.featured === "1"
? true
: undefined,
verified:
search.verified === true || search.verified === "true" || search.verified === "1"
? true
: undefined,
executesCode:
search.executesCode === true ||
search.executesCode === "true" ||
search.executesCode === "1"
search.executesCode === true || search.executesCode === "true" || search.executesCode === "1"
? true
: undefined,
}),
@@ -61,6 +64,7 @@ export const Route = createFileRoute("/plugins/")({
q: deps.q,
cursor: deps.q ? undefined : deps.cursor,
family: deps.family,
featured: deps.featured,
isOfficial: deps.verified,
executesCode: deps.executesCode,
limit: 50,
@@ -100,14 +104,14 @@ function PluginsIndex() {
const search = Route.useSearch();
const navigate = Route.useNavigate();
const loaderData = Route.useLoaderData() as PluginsLoaderData | undefined;
// Defensive handling for when loader data is unavailable (SSR errors, etc.)
const items = loaderData?.items ?? [];
const nextCursor = loaderData?.nextCursor ?? null;
const rateLimited = loaderData?.rateLimited ?? false;
const retryAfterSeconds = loaderData?.retryAfterSeconds ?? null;
const apiError = loaderData?.apiError ?? !loaderData;
const [query, setQuery] = useState(search.q ?? "");
const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -136,12 +140,24 @@ function PluginsIndex() {
};
const handleFamilySort = (value: string) => {
const family =
value === "code-plugin" || value === "bundle-plugin" ? value : undefined;
if (value === "featured") {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
featured: true,
family: undefined,
}),
});
return;
}
const family = value === "code-plugin" || value === "bundle-plugin" ? value : undefined;
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
featured: undefined,
family: family as "code-plugin" | "bundle-plugin" | undefined,
}),
});
@@ -200,11 +216,12 @@ function PluginsIndex() {
<div className={`browse-layout${sidebarOpen ? " sidebar-open" : ""}`}>
<BrowseSidebar
sortOptions={[
{ value: "featured", label: "Featured" },
{ value: "all", label: "All types" },
{ value: "code-plugin", label: "Code plugins" },
{ value: "bundle-plugin", label: "Bundle plugins" },
]}
activeSort={search.family ?? "all"}
activeSort={search.featured ? "featured" : (search.family ?? "all")}
onSortChange={handleFamilySort}
filters={[
{ key: "verified", label: "Verified only", active: search.verified ?? false },
@@ -231,9 +248,7 @@ function PluginsIndex() {
<div className="empty-state">
<AlertTriangle size={20} aria-hidden="true" />
<p className="empty-state-title">Plugin catalog is temporarily unavailable</p>
<p className="empty-state-body">
Try again {formatRetryDelay(retryAfterSeconds)}.
</p>
<p className="empty-state-body">Try again {formatRetryDelay(retryAfterSeconds)}.</p>
</div>
) : items.length === 0 ? (
<div className="empty-state">
+13 -43
View File
@@ -3,28 +3,24 @@ import { Search } from "lucide-react";
import { useEffect, useState } from "react";
import { PluginListItem } from "../components/PluginListItem";
import { SkillListItem } from "../components/SkillListItem";
import { UserListItem } from "../components/UserListItem";
import { Card } from "../components/ui/card";
import type { PublicSkill, PublicUser } from "../lib/publicUser";
import type { PublicSkill } from "../lib/publicUser";
import {
useUnifiedSearch,
type UnifiedSearchType,
type UnifiedPluginResult,
type UnifiedSkillResult,
type UnifiedUserResult,
} from "../lib/useUnifiedSearch";
type SearchState = {
q?: string;
type?: "all" | "skills" | "plugins" | "users";
type?: UnifiedSearchType;
};
export const Route = createFileRoute("/search")({
validateSearch: (search): SearchState => ({
q: typeof search.q === "string" && search.q.trim() ? search.q : undefined,
type:
search.type === "skills" || search.type === "plugins" || search.type === "users"
? search.type
: undefined,
type: search.type === "skills" || search.type === "plugins" ? search.type : undefined,
}),
component: UnifiedSearchPage,
});
@@ -39,7 +35,7 @@ function UnifiedSearchPage() {
setQuery(search.q ?? "");
}, [search.q]);
const { results, skillCount, pluginCount, userCount, isSearching } = useUnifiedSearch(
const { results, skillCount, pluginCount, isSearching } = useUnifiedSearch(
search.q ?? "",
activeType,
);
@@ -52,7 +48,7 @@ function UnifiedSearchPage() {
});
};
const setType = (type: "all" | "skills" | "plugins" | "users") => {
const setType = (type: UnifiedSearchType) => {
void navigate({
to: "/search",
search: { q: search.q, type: type === "all" ? undefined : type },
@@ -74,11 +70,11 @@ function UnifiedSearchPage() {
<form className="search-page-form" onSubmit={handleSearch}>
<div className="browse-search-bar max-w-[560px] flex-1">
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="browse-search-input"
type="text"
placeholder="Search skills, plugins, users..."
placeholder="Search skills and plugins..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
@@ -100,9 +96,7 @@ function UnifiedSearchPage() {
onClick={() => setType("skills")}
>
Skills
{skillCount > 0 ? (
<span className="search-tab-count">{skillCount}</span>
) : null}
{skillCount > 0 ? <span className="search-tab-count">{skillCount}</span> : null}
</button>
<button
className={`search-tab${activeType === "plugins" ? " is-active" : ""}`}
@@ -110,17 +104,7 @@ function UnifiedSearchPage() {
onClick={() => setType("plugins")}
>
Plugins
{pluginCount > 0 ? (
<span className="search-tab-count">{pluginCount}</span>
) : null}
</button>
<button
className={`search-tab${activeType === "users" ? " is-active" : ""}`}
type="button"
onClick={() => setType("users")}
>
Users
{userCount > 0 ? <span className="search-tab-count">{userCount}</span> : null}
{pluginCount > 0 ? <span className="search-tab-count">{pluginCount}</span> : null}
</button>
</div>
@@ -130,9 +114,7 @@ function UnifiedSearchPage() {
</Card>
) : !search.q ? (
<Card className="text-center p-10">
<p className="text-ink-soft">
Enter a search term to find skills, plugins, and users
</p>
<p className="text-ink-soft">Enter a search term to find skills and plugins</p>
</Card>
) : results.length === 0 ? (
<Card className="text-center p-10">
@@ -143,10 +125,8 @@ function UnifiedSearchPage() {
{results.map((item) =>
item.type === "skill" ? (
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
) : item.type === "plugin" ? (
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
) : (
<UserResultRow key={`user-${item.user._id}`} result={item} />
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
),
)}
</div>
@@ -157,19 +137,9 @@ function UnifiedSearchPage() {
function SkillResultRow({ result }: { result: UnifiedSkillResult }) {
const skill = result.skill as unknown as PublicSkill;
return (
<SkillListItem
skill={skill}
ownerHandle={result.ownerHandle}
/>
);
return <SkillListItem skill={skill} ownerHandle={result.ownerHandle} />;
}
function PluginResultRow({ result }: { result: UnifiedPluginResult }) {
return <PluginListItem item={result.plugin} />;
}
function UserResultRow({ result }: { result: UnifiedUserResult }) {
const user = result.user as PublicUser;
return <UserListItem user={user} />;
}
+85 -31
View File
@@ -1,4 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useMutation, useQuery } from "convex/react";
import {
Eye,
@@ -9,6 +9,7 @@ import {
Moon,
RotateCcw,
Settings2,
Star,
Sun,
} from "lucide-react";
import { useEffect, useState } from "react";
@@ -257,6 +258,21 @@ export function Settings() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Star size={18} />
Stars
</CardTitle>
<CardDescription>Review skills you&apos;ve starred for quick access.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<Link to="/stars">View stars</Link>
</Button>
</CardContent>
</Card>
{/* Edit profile form */}
<Card>
<form className="flex flex-col gap-4" onSubmit={onSave}>
@@ -297,9 +313,7 @@ export function Settings() {
<Settings2 size={18} />
Customization
</CardTitle>
<CardDescription>
Personalize your ClawHub experience
</CardDescription>
<CardDescription>Personalize your ClawHub experience</CardDescription>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="advanced-mode" className="text-sm text-[color:var(--ink-soft)]">
@@ -316,7 +330,9 @@ export function Settings() {
<CardContent className="space-y-6">
{/* Theme Section */}
<div className="space-y-3">
<Label id="theme" className="text-sm font-semibold text-[color:var(--ink)]">Theme</Label>
<Label id="theme" className="text-sm font-semibold text-[color:var(--ink)]">
Theme
</Label>
<div className="flex flex-wrap gap-2">
<Button
variant={themeMode === "light" ? "primary" : "ghost"}
@@ -392,7 +408,7 @@ export function Settings() {
{/* Layout Section */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)]">Layout</Label>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="layout-density" className="text-xs text-[color:var(--ink-soft)]">
@@ -400,7 +416,9 @@ export function Settings() {
</Label>
<Select
value={preferences.layoutDensity}
onValueChange={(value) => updatePreference("layoutDensity", value as LayoutDensity)}
onValueChange={(value) =>
updatePreference("layoutDensity", value as LayoutDensity)
}
>
<SelectTrigger id="layout-density">
<SelectValue />
@@ -428,7 +446,9 @@ export function Settings() {
</Label>
<Select
value={preferences.listViewMode}
onValueChange={(value) => updatePreference("listViewMode", value as ListViewMode)}
onValueChange={(value) =>
updatePreference("listViewMode", value as ListViewMode)
}
>
<SelectTrigger id="list-view">
<SelectValue />
@@ -453,7 +473,9 @@ export function Settings() {
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="show-descriptions" className="text-sm">Show descriptions</Label>
<Label htmlFor="show-descriptions" className="text-sm">
Show descriptions
</Label>
<Switch
id="show-descriptions"
checked={preferences.showDescriptions}
@@ -461,7 +483,9 @@ export function Settings() {
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="show-stats" className="text-sm">Show statistics</Label>
<Label htmlFor="show-stats" className="text-sm">
Show statistics
</Label>
<Switch
id="show-stats"
checked={preferences.showStats}
@@ -469,7 +493,9 @@ export function Settings() {
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="show-tags" className="text-sm">Show tags</Label>
<Label htmlFor="show-tags" className="text-sm">
Show tags
</Label>
<Switch
id="show-tags"
checked={preferences.showTags}
@@ -477,7 +503,9 @@ export function Settings() {
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="sticky-header" className="text-sm">Sticky header</Label>
<Label htmlFor="sticky-header" className="text-sm">
Sticky header
</Label>
<Switch
id="sticky-header"
checked={preferences.stickyHeader}
@@ -496,15 +524,20 @@ export function Settings() {
<Label className="text-sm font-semibold text-[color:var(--ink)]">
Code &amp; Content
</Label>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="code-font-size" className="text-xs text-[color:var(--ink-soft)]">
<Label
htmlFor="code-font-size"
className="text-xs text-[color:var(--ink-soft)]"
>
Code font size
</Label>
<Select
value={preferences.codeFontSize}
onValueChange={(value) => updatePreference("codeFontSize", value as CodeFontSize)}
onValueChange={(value) =>
updatePreference("codeFontSize", value as CodeFontSize)
}
>
<SelectTrigger id="code-font-size">
<SelectValue />
@@ -518,20 +551,23 @@ export function Settings() {
</div>
<div className="space-y-2">
<Label htmlFor="animation-level" className="text-xs text-[color:var(--ink-soft)]">
<Label
htmlFor="animation-level"
className="text-xs text-[color:var(--ink-soft)]"
>
Animation level
</Label>
<Select
value={preferences.animationLevel}
onValueChange={(value) => updatePreference("animationLevel", value as AnimationLevel)}
onValueChange={(value) =>
updatePreference("animationLevel", value as AnimationLevel)
}
>
<SelectTrigger id="animation-level">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full">
Full
</SelectItem>
<SelectItem value="full">Full</SelectItem>
<SelectItem value="reduced">Reduced</SelectItem>
<SelectItem value="none">None</SelectItem>
</SelectContent>
@@ -541,7 +577,9 @@ export function Settings() {
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="line-numbers" className="text-sm">Line numbers in code</Label>
<Label htmlFor="line-numbers" className="text-sm">
Line numbers in code
</Label>
<Switch
id="line-numbers"
checked={preferences.lineNumbers}
@@ -549,7 +587,9 @@ export function Settings() {
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="word-wrap" className="text-sm">Word wrap in code</Label>
<Label htmlFor="word-wrap" className="text-sm">
Word wrap in code
</Label>
<Switch
id="word-wrap"
checked={preferences.wordWrap}
@@ -567,11 +607,13 @@ export function Settings() {
<Eye size={14} className="text-[color:var(--accent)]" />
Accessibility
</Label>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="reduced-motion" className="text-sm">Reduced motion</Label>
<Label htmlFor="reduced-motion" className="text-sm">
Reduced motion
</Label>
<p className="text-xs text-[color:var(--ink-soft)]">Minimize animations</p>
</div>
<Switch
@@ -582,8 +624,12 @@ export function Settings() {
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="high-contrast" className="text-sm">High contrast</Label>
<p className="text-xs text-[color:var(--ink-soft)]">Increase color contrast</p>
<Label htmlFor="high-contrast" className="text-sm">
High contrast
</Label>
<p className="text-xs text-[color:var(--ink-soft)]">
Increase color contrast
</p>
</div>
<Switch
id="high-contrast"
@@ -601,16 +647,22 @@ export function Settings() {
<Label className="text-sm font-semibold text-[color:var(--ink)]">
Experimental
</Label>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="experimental-features" className="text-sm">Enable experimental features</Label>
<p className="text-xs text-[color:var(--ink-soft)]">Try new features before they&apos;re released</p>
<Label htmlFor="experimental-features" className="text-sm">
Enable experimental features
</Label>
<p className="text-xs text-[color:var(--ink-soft)]">
Try new features before they&apos;re released
</p>
</div>
<Switch
id="experimental-features"
checked={preferences.experimentalFeatures}
onCheckedChange={(checked) => updatePreference("experimentalFeatures", checked)}
onCheckedChange={(checked) =>
updatePreference("experimentalFeatures", checked)
}
/>
</div>
</div>
@@ -623,7 +675,9 @@ export function Settings() {
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[color:var(--ink)]">Reset preferences</p>
<p className="text-xs text-[color:var(--ink-soft)]">Restore all settings to defaults</p>
<p className="text-xs text-[color:var(--ink-soft)]">
Restore all settings to defaults
</p>
</div>
<Button
variant="ghost"
+15 -12
View File
@@ -15,6 +15,7 @@ export type SkillsSearchState = {
sort?: SortKey;
dir?: SortDir;
highlighted?: boolean;
featured?: boolean;
nonSuspicious?: boolean;
tag?: string;
view?: SkillsView;
@@ -57,7 +58,7 @@ export function useSkillsBrowseModel({
const navigateTimer = useRef<number>(0);
const view: SkillsView = search.view ?? "list";
const highlightedOnly = search.highlighted ?? false;
const featuredOnly = search.featured ?? search.highlighted ?? false;
const nonSuspiciousOnly = search.nonSuspicious ?? false;
const capabilityTag = search.tag;
const searchSkills = useAction(api.search.searchSkills);
@@ -72,7 +73,7 @@ export function useSkillsBrowseModel({
const listSort = toListSort(sort);
const dir = parseDir(search.dir, sort);
const searchKey = trimmedQuery
? `${trimmedQuery}::${highlightedOnly ? "1" : "0"}::${nonSuspiciousOnly ? "1" : "0"}::${capabilityTag ?? ""}`
? `${trimmedQuery}::${featuredOnly ? "1" : "0"}::${nonSuspiciousOnly ? "1" : "0"}::${capabilityTag ?? ""}`
: "";
// One-shot paginated fetches (no reactive subscription)
@@ -89,7 +90,7 @@ export function useSkillsBrowseModel({
numItems: pageSize,
sort: listSort,
dir,
highlightedOnly,
highlightedOnly: featuredOnly,
nonSuspiciousOnly,
capabilityTag,
});
@@ -105,7 +106,7 @@ export function useSkillsBrowseModel({
setListStatus(cursor ? "idle" : "done");
}
},
[capabilityTag, dir, highlightedOnly, listSort, nonSuspiciousOnly],
[capabilityTag, dir, featuredOnly, listSort, nonSuspiciousOnly],
);
// Reset and fetch first page when sort/dir/filters change
@@ -155,7 +156,7 @@ export function useSkillsBrowseModel({
try {
const data = (await searchSkills({
query: trimmedQuery,
highlightedOnly,
highlightedOnly: featuredOnly,
nonSuspiciousOnly,
capabilityTag,
limit: searchLimit,
@@ -174,7 +175,7 @@ export function useSkillsBrowseModel({
}, [
capabilityTag,
hasQuery,
highlightedOnly,
featuredOnly,
nonSuspiciousOnly,
searchLimit,
searchSkills,
@@ -197,7 +198,8 @@ export function useSkillsBrowseModel({
const sorted = useMemo(() => {
if (isOtherCategory) {
return baseItems.filter((entry) => {
const text = `${entry.skill.displayName} ${entry.skill.summary ?? ""} ${entry.skill.slug}`.toLowerCase();
const text =
`${entry.skill.displayName} ${entry.skill.summary ?? ""} ${entry.skill.slug}`.toLowerCase();
return !ALL_CATEGORY_KEYWORDS.some((kw) => text.includes(kw));
});
}
@@ -319,11 +321,12 @@ export function useSkillsBrowseModel({
[navigate],
);
const onToggleHighlighted = useCallback(() => {
const onToggleFeatured = useCallback(() => {
void navigate({
search: (prev) => ({
...prev,
highlighted: prev.highlighted ? undefined : true,
featured: prev.featured || prev.highlighted ? undefined : true,
highlighted: undefined,
}),
replace: true,
});
@@ -375,7 +378,7 @@ export function useSkillsBrowseModel({
}, [navigate]);
const activeFilters: string[] = [];
if (highlightedOnly) activeFilters.push("highlighted");
if (featuredOnly) activeFilters.push("featured");
if (nonSuspiciousOnly) activeFilters.push("non-suspicious");
if (capabilityTag) activeFilters.push(SKILL_CAPABILITY_LABELS[capabilityTag] ?? capabilityTag);
@@ -399,7 +402,7 @@ export function useSkillsBrowseModel({
canLoadMore,
dir,
hasQuery,
highlightedOnly,
featuredOnly,
isLoadingMore,
isLoadingSkills,
loadMore,
@@ -409,7 +412,7 @@ export function useSkillsBrowseModel({
onQueryChange,
onSortChange,
onToggleDir,
onToggleHighlighted,
onToggleFeatured,
onToggleNonSuspicious,
onToggleView,
query,
+60 -30
View File
@@ -6,7 +6,7 @@ import { api } from "../../../convex/_generated/api";
import { BrowseSidebar } from "../../components/BrowseSidebar";
import { SKILL_CATEGORIES } from "../../lib/categories";
import { formatCompactStat } from "../../lib/numberFormat";
import { parseSort } from "./-params";
import { parseDir, parseSort } from "./-params";
import { SkillsResults } from "./-SkillsResults";
import { useSkillsBrowseModel, type SkillsSearchState } from "./-useSkillsBrowseModel";
@@ -29,6 +29,10 @@ export const Route = createFileRoute("/skills/")({
search.highlighted === "1" || search.highlighted === "true" || search.highlighted === true
? true
: undefined,
featured:
search.featured === "1" || search.featured === "true" || search.featured === true
? true
: undefined,
nonSuspicious:
search.nonSuspicious === "1" ||
search.nonSuspicious === "true" ||
@@ -41,7 +45,9 @@ export const Route = createFileRoute("/skills/")({
},
beforeLoad: ({ search }) => {
const hasQuery = Boolean(search.q?.trim());
if (hasQuery || search.sort) return;
if (hasQuery || search.sort || search.featured || search.highlighted || search.nonSuspicious) {
return;
}
throw redirect({
to: "/skills",
search: {
@@ -49,6 +55,7 @@ export const Route = createFileRoute("/skills/")({
sort: "downloads",
dir: search.dir || undefined,
highlighted: search.highlighted || undefined,
featured: search.featured || undefined,
nonSuspicious: search.nonSuspicious || undefined,
view: search.view || undefined,
focus: search.focus || undefined,
@@ -64,8 +71,7 @@ export function SkillsIndex() {
const search = Route.useSearch();
const searchInputRef = useRef<HTMLInputElement>(null);
const totalSkills = useQuery(api.skills.countPublicSkills);
const totalSkillsText =
typeof totalSkills === "number" ? formatCompactStat(totalSkills) : null;
const totalSkillsText = typeof totalSkills === "number" ? formatCompactStat(totalSkills) : null;
const [sidebarOpen, setSidebarOpen] = useState(false);
const model = useSkillsBrowseModel({
@@ -80,12 +86,50 @@ export function SkillsIndex() {
const handleFilterToggle = useCallback(
(key: string) => {
if (key === "highlighted") model.onToggleHighlighted();
else if (key === "nonSuspicious") model.onToggleNonSuspicious();
if (key === "nonSuspicious") model.onToggleNonSuspicious();
},
[model.onToggleHighlighted, model.onToggleNonSuspicious],
[model.onToggleNonSuspicious],
);
const handleSortChange = useCallback(
(value: string) => {
if (value === "featured") {
if (!model.featuredOnly) model.onToggleFeatured();
return;
}
if (model.featuredOnly) {
const nextSort = parseSort(value);
void navigate({
search: (prev) => ({
...prev,
sort: nextSort,
dir: parseDir(prev.dir, nextSort),
featured: undefined,
highlighted: undefined,
}),
replace: true,
});
return;
}
model.onSortChange(value);
},
[model.featuredOnly, model.onSortChange, model.onToggleFeatured, navigate],
);
const handleClear = useCallback(() => {
model.onQueryChange("");
if (model.featuredOnly) model.onToggleFeatured();
if (model.nonSuspiciousOnly) model.onToggleNonSuspicious();
}, [
model.featuredOnly,
model.onQueryChange,
model.onToggleFeatured,
model.onToggleNonSuspicious,
model.nonSuspiciousOnly,
]);
const handleCategoryChange = useCallback(
(slug: string | undefined) => {
if (slug) {
@@ -103,9 +147,8 @@ export function SkillsIndex() {
const activeCategory = useMemo(() => {
if (!model.query) return undefined;
return (
SKILL_CATEGORIES.find((c) =>
c.keywords.some((k) => k === model.query.trim().toLowerCase()),
)?.slug ?? undefined
SKILL_CATEGORIES.find((c) => c.keywords.some((k) => k === model.query.trim().toLowerCase()))
?.slug ?? undefined
);
}, [model.query]);
@@ -122,9 +165,7 @@ export function SkillsIndex() {
</button>
<h1 className="browse-title">
Skills
{totalSkillsText ? (
<span className="browse-count">{totalSkillsText}</span>
) : null}
{totalSkillsText ? <span className="browse-count">{totalSkillsText}</span> : null}
</h1>
</div>
<div className="browse-page-search">
@@ -142,11 +183,10 @@ export function SkillsIndex() {
categories={SKILL_CATEGORIES}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
sortOptions={sortOptionsWithRelevance}
activeSort={model.sort}
onSortChange={model.onSortChange}
sortOptions={[{ value: "featured", label: "Featured" }, ...sortOptionsWithRelevance]}
activeSort={model.featuredOnly ? "featured" : model.sort}
onSortChange={handleSortChange}
filters={[
{ key: "highlighted", label: "Staff picks", active: model.highlightedOnly },
{ key: "nonSuspicious", label: "Hide suspicious", active: model.nonSuspiciousOnly },
]}
onFilterToggle={handleFilterToggle}
@@ -154,19 +194,9 @@ export function SkillsIndex() {
<div className="browse-results">
<div className="browse-results-toolbar">
<span className="browse-results-count">
{model.isLoadingSkills
? "\u2014"
: `${model.sorted.length} results`}
{(model.hasQuery || model.highlightedOnly || model.nonSuspiciousOnly) ? (
<button
className="browse-clear-btn"
type="button"
onClick={() => {
model.onQueryChange("");
if (model.highlightedOnly) model.onToggleHighlighted();
if (model.nonSuspiciousOnly) model.onToggleNonSuspicious();
}}
>
{model.isLoadingSkills ? "\u2014" : `${model.sorted.length} results`}
{model.hasQuery || model.featuredOnly || model.nonSuspiciousOnly ? (
<button className="browse-clear-btn" type="button" onClick={handleClear}>
Clear
</button>
) : null}
+13 -4
View File
@@ -64,14 +64,23 @@ function SoulsHoldingPage() {
</div>
<div className="skill-card-tags">
<Button asChild variant="primary">
<Link to="/skills" search={{ q: undefined, sort: "downloads", dir: "desc", highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}>
<Link
to="/skills"
search={{
q: undefined,
sort: "downloads",
dir: "desc",
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
>
Browse Skills
</Link>
</Button>
<Button asChild>
<Link to="/users" search={{ q: undefined }}>
Browse Users
</Link>
<Link to="/users">Browse Users</Link>
</Button>
</div>
</section>
+5 -38
View File
@@ -1,38 +1,26 @@
import { createFileRoute } from "@tanstack/react-router";
import { Search } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { api } from "../../../convex/_generated/api";
import { UserListItem } from "../../components/UserListItem";
import { Card } from "../../components/ui/card";
import { UserListItem } from "../../components/UserListItem";
import { convexHttp } from "../../convex/client";
import type { PublicUser } from "../../lib/publicUser";
type UserSearchState = {
q?: string;
};
type UsersLoaderResult = { items: PublicUser[]; total: number };
export const Route = createFileRoute("/users/")({
validateSearch: (search): UserSearchState => ({
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
}),
component: UsersIndex,
});
function UsersIndex() {
const search = Route.useSearch();
const navigate = Route.useNavigate();
const [query, setQuery] = useState(search.q ?? "");
const [result, setResult] = useState<UsersLoaderResult | undefined>(undefined);
const [loading, setLoading] = useState(true);
const fetchUsers = useCallback(async (q?: string) => {
const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const data = await convexHttp.query(api.users.listPublic, {
limit: 48,
search: q,
});
setResult(data as UsersLoaderResult);
} finally {
@@ -41,9 +29,8 @@ function UsersIndex() {
}, []);
useEffect(() => {
setQuery(search.q ?? "");
void fetchUsers(search.q);
}, [search.q, fetchUsers]);
void fetchUsers();
}, [fetchUsers]);
const users = result?.items ?? [];
@@ -57,25 +44,6 @@ function UsersIndex() {
) : null}
</h1>
</div>
<form
className="browse-page-search"
onSubmit={(event) => {
event.preventDefault();
void navigate({
search: {
q: query.trim() || undefined,
},
});
}}
>
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
<input
className="browse-search-input"
placeholder="Search users..."
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</form>
<div className="browse-results">
<div className="browse-results-toolbar">
@@ -90,8 +58,7 @@ function UsersIndex() {
</Card>
) : users.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">No users found</p>
<p className="empty-state-body">Try a different handle or name.</p>
<p className="empty-state-title">No users yet</p>
</div>
) : (
<div className="results-list">
+97 -197
View File
@@ -775,6 +775,11 @@ code {
box-shadow 0.15s ease;
}
.navbar-search-wrap {
position: relative;
min-width: 0;
}
.navbar-search:focus-within {
border-color: var(--input-focus-border);
box-shadow: 0 0 0 2px var(--input-focus-ring);
@@ -807,6 +812,98 @@ code {
opacity: 0.7;
}
.navbar-search-typeahead {
position: absolute;
z-index: 50;
top: calc(100% + 8px);
left: 0;
right: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--surface);
box-shadow: var(--shadow-lg);
}
.navbar-search-typeahead-section + .navbar-search-typeahead-section {
border-top: 1px solid var(--line);
}
.navbar-search-typeahead-heading {
padding: 8px 12px;
background: var(--surface-muted);
color: var(--ink);
font-size: var(--fs-sm);
font-weight: 700;
}
.navbar-search-typeahead-row {
all: unset;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 44px;
padding: 8px 12px;
border-top: 1px solid var(--line);
color: var(--ink);
cursor: pointer;
}
.navbar-search-typeahead-row:hover,
.navbar-search-typeahead-row.is-active {
background: var(--surface-muted);
}
.navbar-search-typeahead-row.is-footer {
color: var(--ink-soft);
}
.navbar-search-typeahead-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: var(--accent-soft);
color: var(--accent);
font-size: var(--fs-xs);
font-weight: 700;
flex: 0 0 auto;
}
.navbar-search-typeahead-copy {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.navbar-search-typeahead-title,
.navbar-search-typeahead-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.navbar-search-typeahead-title {
font-size: var(--fs-sm);
font-weight: 600;
}
.navbar-search-typeahead-meta {
color: var(--ink-soft);
font-size: var(--fs-xs);
}
.navbar-search-typeahead-status {
padding: 12px;
color: var(--ink-soft);
font-size: var(--fs-sm);
}
.navbar-search-home {
justify-content: space-between;
cursor: pointer;
@@ -8510,21 +8607,6 @@ code {
}
/* Headline */
.home-v2-hero-label {
font-family: "JetBrains Mono", monospace;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--hv2-text-tertiary);
margin-bottom: 20px;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
transition:
color 0.2s,
text-shadow 0.3s;
}
.home-v2-headline {
font-family: "Inter", sans-serif;
font-weight: 700;
@@ -8613,159 +8695,6 @@ code {
}
}
/* ═══ SLOT MACHINE EASTER EGG ═══ */
.home-v2-hero-label:hover {
color: var(--hv2-accent);
}
.home-v2-hero-label-active {
color: var(--hv2-accent) !important;
text-shadow: 0 0 12px rgba(212, 69, 58, 0.4);
animation: home-v2-labelPulse 0.6s ease-in-out infinite;
}
@keyframes home-v2-labelPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.home-v2-headline-slots {
min-height: 1.15em;
position: relative;
}
.home-v2-slot-reel {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 180px;
text-align: center;
font-family: "JetBrains Mono", monospace;
font-weight: 800;
color: var(--hv2-accent);
position: relative;
}
.home-v2-slot-reel.spinning .home-v2-slot-word {
animation: home-v2-slotBlur 0.12s steps(1) infinite;
}
.home-v2-slot-reel:not(.spinning) .home-v2-slot-word {
animation: home-v2-slotLand 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes home-v2-slotBlur {
0% {
filter: blur(0px);
opacity: 1;
}
50% {
filter: blur(1px);
opacity: 0.7;
}
100% {
filter: blur(0px);
opacity: 1;
}
}
@keyframes home-v2-slotLand {
0% {
transform: translateY(-8px) scale(1.1);
opacity: 0.5;
}
60% {
transform: translateY(2px) scale(0.98);
}
100% {
transform: translateY(0) scale(1);
opacity: 1;
}
}
.home-v2-headline-jackpot {
animation: home-v2-jackpot 0.5s ease-out;
}
@keyframes home-v2-jackpot {
0% {
transform: scale(1);
}
30% {
transform: scale(1.08);
}
60% {
transform: scale(0.97);
}
100% {
transform: scale(1);
}
}
.home-v2-headline-jackpot .home-v2-slot-word {
color: #ffd93d !important;
text-shadow:
0 0 20px rgba(255, 217, 61, 0.6),
0 0 40px rgba(255, 217, 61, 0.3);
}
/* ═══ HACK × 3 — Lobster / Aquatic Jackpot ═══ */
.home-v2-headline-hack .home-v2-slot-word {
color: #22d3ee !important;
text-shadow:
0 0 24px rgba(34, 211, 238, 0.6),
0 0 48px rgba(6, 182, 212, 0.3),
0 2px 8px rgba(0, 0, 0, 0.4) !important;
}
.home-v2-headline-hack .home-v2-sep {
border-color: #22d3ee;
opacity: 0.8;
box-shadow: 0 0 8px rgba(34, 211, 238, 0.5);
}
.home-v2-hack-lobster {
position: absolute;
top: 50%;
left: 50%;
width: 280px;
height: 280px;
transform: translate(-50%, -50%) scale(0);
opacity: 0;
pointer-events: none;
filter: drop-shadow(0 0 40px rgba(34, 211, 238, 0.5))
drop-shadow(0 0 80px rgba(6, 182, 212, 0.25));
animation: home-v2-lobsterReveal 1.2s 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
z-index: -1;
}
@keyframes home-v2-lobsterReveal {
0% {
transform: translate(-50%, -50%) scale(0) rotate(-30deg);
opacity: 0;
}
50% {
opacity: 0.18;
}
100% {
transform: translate(-50%, -50%) scale(1) rotate(0deg);
opacity: 0.12;
}
}
.home-v2-confetti {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
}
.home-v2-sub {
color: var(--hv2-text-tertiary);
font-size: 16px;
line-height: 1.5;
margin-bottom: 36px;
max-width: 580px;
font-weight: 400;
}
.home-v2-sub-clear {
color: var(--hv2-text-secondary);
max-width: 820px;
margin-bottom: 18px;
}
.home-v2-motto {
display: flex;
flex-direction: column;
@@ -8830,16 +8759,6 @@ code {
.home-v2-search-bar input::placeholder {
color: var(--hv2-text-tertiary);
}
.home-v2-search-bar kbd {
font-family: "Inter", sans-serif;
font-size: 11px;
background: transparent;
border: 1px solid var(--hv2-border);
border-radius: 5px;
padding: 2px 7px;
color: var(--hv2-text-tertiary);
flex-shrink: 0;
}
.home-v2-search-go {
background: var(--hv2-accent-fill);
color: var(--hv2-accent);
@@ -8877,12 +8796,6 @@ code {
margin-top: 16px;
flex-wrap: wrap;
}
.home-v2-suggestions-label {
color: var(--hv2-text-secondary);
font-size: 13px;
font-weight: 500;
margin-right: 4px;
}
.home-v2-suggestion {
display: flex;
align-items: center;
@@ -8918,11 +8831,6 @@ code {
margin-top: 12px;
}
.home-v2-suggestions-label {
font-size: 12px;
margin-right: 2px;
}
.home-v2-suggestion {
font-size: 12px;
padding: 5px 10px;
@@ -9714,11 +9622,6 @@ code {
[data-theme-resolved="light"] .home-v2-search-bar input::placeholder {
color: #9c8b7a;
}
[data-theme-resolved="light"] .home-v2-search-bar kbd {
border-color: rgba(170, 125, 80, 0.2);
color: #9c8b7a;
}
/* Light — search button */
[data-theme-resolved="light"] .home-v2-search-go {
background: rgba(196, 58, 47, 0.06);
@@ -9901,9 +9804,6 @@ code {
.home-v2-search-go {
border-radius: 8px;
}
.home-v2-search-bar kbd {
border-radius: 8px;
}
.home-v2-c-icon {
border-radius: 8px;
}