Compare commits

..
Author SHA1 Message Date
Val Alexander 3c35b8a3b4 Fix dark-mode styling for skills filter chips
- Add readable dark-surface and active-state colors to filter chips
- Cover the toolbar styling with a jsdom test
2026-04-13 12:48:02 -05:00
101 changed files with 4356 additions and 14051 deletions
-4
View File
@@ -27,13 +27,9 @@ jobs:
- name: Test
run: bun run test
env:
VITE_CONVEX_URL: https://example.invalid
- name: Coverage
run: bun run coverage
env:
VITE_CONVEX_URL: https://example.invalid
- name: ClawHub CLI Verify
run: bun run --cwd packages/clawhub verify
-4
View File
@@ -24,7 +24,3 @@ coverage
playwright-report
test-results
.playwright
convex/_generated/
skills-lock.json
*/skills/*
skills/*
-8
View File
@@ -87,11 +87,3 @@
- **32K document limit per query.** Split `.collect()` calls by a partition field (e.g., one day at a time instead of a 7-day range). See `rebuildTrendingLeaderboardAction` in `convex/leaderboards.ts` for an example.
- **Common mistakes**: `.filter().collect()` without an index; `ctx.db.get()` on large docs in a loop for list views; while loops that paginate the whole table to find filtered results.
- **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 -->
-8
View File
@@ -45,11 +45,3 @@
- Tests use `._handler` to call mutation handlers directly with mock `db` objects.
- Mock `db` objects MUST include `normalizeId: vi.fn()` for trigger wrapper compatibility.
<!-- 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 -->
-356
View File
@@ -1,356 +0,0 @@
# ClawHub Design System
This document outlines the design rules, patterns, and guidelines for the ClawHub platform to ensure consistency, accessibility, and maintainability across all components.
---
## Color System
### Brand Palette (OpenClaw)
ClawHub uses a strict **3-5 color palette** based on the OpenClaw brand:
| Token | Light Mode | Dark Mode | Usage |
|-------|------------|-----------|-------|
| `--accent` | `#dc2626` | `#dc2626` | Primary actions, interactive elements, emphasis |
| `--accent-deep` | `#b91c1c` | `#ef4444` | Hover states, secondary emphasis |
| `--ink` | `#0a0a0a` | `#fafafa` | Primary text |
| `--ink-soft` | `#525252` | `#a1a1a1` | Secondary text, descriptions |
| `--surface` | `#ffffff` | `#121212` | Card backgrounds, elevated surfaces |
| `--bg` | `#fafafa` | `#0a0a0a` | Page background |
### Rules
1. **Never exceed 5 colors** without explicit design approval
2. **Never use purple/violet prominently** unless explicitly requested
3. **Always override text color** when changing background color to ensure contrast
4. **Use semantic tokens** (`--accent`, `--ink`, `--surface`) instead of raw colors
---
## Typography
### Font Stack
```css
--font-sans: 'Geist', system-ui, sans-serif;
--font-mono: 'Geist Mono', monospace;
--font-display: 'Geist', system-ui, sans-serif;
```
### Scale
| Token | Size | Usage |
|-------|------|-------|
| `--fs-xs` | 0.75rem (12px) | Labels, badges, metadata |
| `--fs-sm` | 0.875rem (14px) | Body text, descriptions |
| `--fs-base` | 1rem (16px) | Default body text |
| `--fs-md` | 1.125rem (18px) | Subheadings |
| `--fs-lg` | 1.25rem (20px) | Section titles |
| `--fs-xl` | 1.5rem (24px) | Page headings |
### Rules
1. **Maximum 2 font families** per page
2. **Line height 1.4-1.6** for body text (use `leading-relaxed`)
3. **Never use decorative fonts** for body text
4. **Minimum font size: 14px** for readability
5. Use `text-balance` or `text-pretty` for titles
---
## Layout
### Method Priority
Use this hierarchy for layout decisions:
1. **Flexbox** - Default for most layouts
2. **CSS Grid** - Only for complex 2D layouts (cards, galleries)
3. **Never use floats** or absolute positioning unless absolutely necessary
### Spacing Scale
```css
--space-1: 0.25rem /* 4px */
--space-2: 0.5rem /* 8px */
--space-3: 0.75rem /* 12px */
--space-4: 1rem /* 16px */
--space-5: 1.5rem /* 24px */
--space-6: 2rem /* 32px */
```
### Grid Patterns
#### Auto-fit Grid (Recommended for Cards)
```css
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
```
- Automatically adjusts columns based on container width
- Prevents orphan items on partial rows
- Maintains consistent card widths
#### Fixed Grid (When exact columns needed)
```css
/* 3-column at desktop, 2 at tablet, 1 at mobile */
grid-template-columns: repeat(3, minmax(0, 1fr));
@media (max-width: 860px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 520px) {
grid-template-columns: 1fr;
}
```
### Container Widths
| Size | Max Width | Usage |
|------|-----------|-------|
| Default | `--page-max` (1200px) | Standard pages |
| Narrow | `--page-narrow` (720px) | Reading content, forms |
| Wide | Full width | Dashboards, data tables |
---
## Components
### Cards
```css
.card {
padding: var(--space-4);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--surface);
}
```
**Rules:**
- Always use `display: flex; flex-direction: column;` for consistent height
- Add `flex: 1` to content area for equal-height cards in grids
- Include hover state with `border-color` and subtle `box-shadow`
### Buttons
| Variant | Usage |
|---------|-------|
| `primary` | Main actions (Submit, Save, Download) |
| `secondary` | Alternative actions |
| `ghost` | Tertiary actions, navigation |
| `destructive` | Delete, remove, dangerous actions |
**Rules:**
- Always include visible focus state
- Minimum touch target: 44x44px on mobile
- Include `aria-label` when icon-only
### Form Controls
- Labels above inputs (not inline)
- Error states use `--status-error-fg`
- Focus rings use `--accent` with 0.2 opacity
- Minimum input height: 40px
---
## Responsive Breakpoints
```css
/* Mobile first - base styles for mobile */
@media (min-width: 520px) {
/* Small tablets, large phones */
}
@media (min-width: 640px) {
/* Tablets */
}
@media (min-width: 860px) {
/* Small desktops, landscape tablets */
}
@media (min-width: 1024px) {
/* Desktops */
}
@media (min-width: 1280px) {
/* Large desktops */
}
```
### Rules
1. **Mobile-first approach** - Base styles target mobile
2. **Progressive enhancement** - Add complexity as viewport increases
3. **Test intermediate breakpoints** - Avoid jarring layout jumps
4. **Never hide critical content** on mobile
---
## Accessibility
### Color Contrast
- Normal text: Minimum 4.5:1 ratio
- Large text (18px+): Minimum 3:1 ratio
- Interactive elements: Minimum 3:1 ratio
### Focus States
```css
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 2px;
}
```
### Screen Readers
- Use `sr-only` class for visually hidden but accessible text
- Always include `alt` text for images (empty `alt=""` for decorative)
- Use semantic HTML elements (`main`, `nav`, `article`, `section`)
- Proper heading hierarchy (h1 > h2 > h3, no skipping)
### Motion
```css
/* Respect user preference */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
---
## Animation
### Timing
```css
--transition-fast: 150ms;
--transition-base: 200ms;
--transition-slow: 300ms;
```
### Easing
- Use `ease` or `ease-out` for most transitions
- Use `ease-in-out` for enter/exit animations
- Never use `linear` except for continuous animations
### Rules
1. **Subtle by default** - Avoid flashy animations
2. **Purpose-driven** - Animation should provide feedback
3. **Respect preferences** - Support `prefers-reduced-motion`
4. **Performance** - Use `transform` and `opacity` only
---
## Icons
### Usage
- Use Lucide icons consistently
- Standard sizes: 14px, 16px, 20px, 24px
- Include `aria-hidden="true"` for decorative icons
- Never use emojis as icons
### Placement
- Left of labels in buttons and navigation
- Right of labels for external links or dropdowns
- Centered when used alone with `aria-label`
---
## Dark Mode
### Implementation
```css
[data-theme="dark"] {
/* Dark mode overrides */
}
```
### Rules
1. Never use pure white (`#ffffff`) on dark backgrounds
2. Reduce shadow intensity in dark mode
3. Adjust image brightness if needed
4. Test contrast ratios in both modes
---
## Performance
### CSS
1. Use CSS custom properties for theming
2. Avoid deeply nested selectors (max 3 levels)
3. Use `will-change` sparingly
4. Prefer `transform` over `top/left` for animations
### Images
1. Always specify `width` and `height` attributes
2. Use `loading="lazy"` for below-fold images
3. Use appropriate formats (WebP with fallbacks)
4. Include placeholder or skeleton states
---
## Code Style
### CSS Class Naming
```css
/* Component */
.component-name { }
/* Component modifier */
.component-name.variant { }
/* Component child */
.component-name-child { }
/* State */
.component-name.is-active { }
.component-name[data-state="open"] { }
```
### File Organization
```
src/
components/
ui/ # Primitive components (Button, Input, Card)
layout/ # Layout components (Container, Header)
styles.css # Global styles and design tokens
lib/
theme.ts # Theme utilities
preferences.ts # User preference management
```
---
## Checklist
Before shipping any UI changes, verify:
- [ ] Color contrast meets WCAG AA standards
- [ ] Focus states are visible
- [ ] Layout works at all breakpoints
- [ ] Animations respect `prefers-reduced-motion`
- [ ] Text is readable at default browser zoom
- [ ] Interactive elements have 44px minimum touch target
- [ ] Semantic HTML is used appropriately
- [ ] Dark mode has been tested
+3 -139
View File
@@ -20,8 +20,6 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -34,7 +32,6 @@
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "1.167.2",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clawhub-schema": "workspace:*",
"clsx": "^2.1.1",
"convex": "^1.34.1",
@@ -44,8 +41,6 @@
"ignore": "^7.0.5",
"lucide-react": "^0.577.0",
"monaco-editor": "^0.55.1",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"nitro": "3.0.260311-beta",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -56,7 +51,6 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"vite-tsconfig-paths": "^6.1.1",
"yaml": "^2.8.3",
"zod": "^4.3.6",
@@ -272,56 +266,6 @@
"@fontsource/manrope": ["@fontsource/manrope@5.2.8", "", {}, "sha512-gJHJmcuUk7qWcNCfcAri/DJQtXtBYqi9yKratr4jXhSo0I3xUtNNKI+igQIcw5c+m95g0vounk8ZnX/kb8o0TA=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -338,24 +282,6 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="],
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
"@oozcitak/infra": ["@oozcitak/infra@2.0.2", "", { "dependencies": { "@oozcitak/util": "^10.0.0" } }, "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA=="],
@@ -514,9 +440,7 @@
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
@@ -612,8 +536,6 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
@@ -822,8 +744,6 @@
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clawhub": ["clawhub@workspace:packages/clawhub"],
"clawhub-schema": ["clawhub-schema@workspace:packages/schema"],
@@ -832,8 +752,6 @@
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
@@ -1178,10 +1096,6 @@
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"nf3": ["nf3@0.3.13", "", {}, "sha512-drDt0yl4d/yUhlpD0GzzqahSpA5eUNeIfFq0/aoZb0UlPY0ZwP4u1EfREVvZrYdEnJ3OU9Le9TrzbvWgEkkeKw=="],
"nitro": ["nitro@3.0.260311-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.4", "db0": "^0.3.4", "env-runner": "^0.1.6", "h3": "^2.0.1-rc.16", "hookable": "^6.0.1", "nf3": "^0.3.11", "ocache": "^0.1.2", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.0-rc.8", "srvx": "^0.11.9", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.6" }, "peerDependencies": { "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.59.0", "vite": "^7 || ^8 || >=8.0.0-0", "xml2js": "^0.6.2", "zephyr-agent": "^0.1.15" }, "optionalPeers": ["dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-0o0fJ9LUh4WKUqJNX012jyieUOtMCnadkNDWr0mHzdraoHpJP/1CGNefjRyZyMXSpoJfwoWdNEZu2iGf35TUvQ=="],
@@ -1310,8 +1224,6 @@
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
@@ -1352,8 +1264,6 @@
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
@@ -1398,8 +1308,6 @@
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
@@ -1492,14 +1400,10 @@
"@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-dropdown-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
@@ -1512,14 +1416,14 @@
"@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
@@ -1532,12 +1436,6 @@
"@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-switch/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
@@ -1552,8 +1450,6 @@
"@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
@@ -1596,11 +1492,7 @@
"jsdom/undici": ["undici@7.24.5", "", {}, "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q=="],
<<<<<<< staging
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
=======
"nitro/rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
>>>>>>> main
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
@@ -1618,33 +1510,6 @@
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
<<<<<<< staging
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-switch/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-toggle-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-toggle/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
=======
"vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"vitest/vite": ["vite@8.0.1", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw=="],
@@ -1718,6 +1583,5 @@
"vitest/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
"vitest/vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
>>>>>>> main
}
}
-2
View File
@@ -100,7 +100,6 @@ import type * as publishers from "../publishers.js";
import type * as rateLimits from "../rateLimits.js";
import type * as search from "../search.js";
import type * as seed from "../seed.js";
import type * as seedDemo from "../seedDemo.js";
import type * as seedSouls from "../seedSouls.js";
import type * as skillStatEvents from "../skillStatEvents.js";
import type * as skillTransfers from "../skillTransfers.js";
@@ -217,7 +216,6 @@ declare const fullApi: ApiFromModules<{
rateLimits: typeof rateLimits;
search: typeof search;
seed: typeof seed;
seedDemo: typeof seedDemo;
seedSouls: typeof seedSouls;
skillStatEvents: typeof skillStatEvents;
skillTransfers: typeof skillTransfers;
-5
View File
@@ -469,11 +469,6 @@ export const seedSkillMutation = internalMutation({
createdAt: now,
updatedAt: now,
});
await ctx.db.patch(userId, {
publishedSkills: 1,
totalStars: 0,
totalDownloads: 0,
});
const versionId = await ctx.db.insert("skillVersions", {
skillId,
+1 -1
View File
@@ -1,6 +1,6 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
extractWorkflowFilenameFromWorkflowRef,
verifyGitHubActionsTrustedPublishJwt,
-67
View File
@@ -1,67 +0,0 @@
import type { Doc, Id } from "../_generated/dataModel";
import type { MutationCtx } from "../_generated/server";
function getSkillContribution(skill: Doc<"skills">) {
if (skill.softDeletedAt) {
return { publishedSkills: 0, totalStars: 0, totalDownloads: 0 };
}
return {
publishedSkills: 1,
totalStars: skill.stats?.stars ?? 0,
totalDownloads: skill.stats?.downloads ?? 0,
};
}
async function patchUserStats(
ctx: Pick<MutationCtx, "db">,
userId: Id<"users">,
delta: { publishedSkills: number; totalStars: number; totalDownloads: number },
) {
const user = await ctx.db.get(userId);
if (!user) return;
await ctx.db.patch(userId, {
publishedSkills: Math.max(0, (user.publishedSkills ?? 0) + delta.publishedSkills),
totalStars: Math.max(0, (user.totalStars ?? 0) + delta.totalStars),
totalDownloads: Math.max(0, (user.totalDownloads ?? 0) + delta.totalDownloads),
});
}
export async function adjustUserSkillStatsForSkillChange(
ctx: Pick<MutationCtx, "db">,
previousSkill: Doc<"skills"> | null | undefined,
nextSkill: Doc<"skills"> | null | undefined,
) {
if (!previousSkill && !nextSkill) return;
const prevOwnerId = previousSkill?.ownerUserId ?? null;
const nextOwnerId = nextSkill?.ownerUserId ?? null;
const prevContribution = previousSkill ? getSkillContribution(previousSkill) : null;
const nextContribution = nextSkill ? getSkillContribution(nextSkill) : null;
if (prevOwnerId && prevOwnerId === nextOwnerId) {
await patchUserStats(ctx, prevOwnerId, {
publishedSkills: (nextContribution?.publishedSkills ?? 0) - (prevContribution?.publishedSkills ?? 0),
totalStars: (nextContribution?.totalStars ?? 0) - (prevContribution?.totalStars ?? 0),
totalDownloads: (nextContribution?.totalDownloads ?? 0) - (prevContribution?.totalDownloads ?? 0),
});
return;
}
if (prevOwnerId) {
await patchUserStats(ctx, prevOwnerId, {
publishedSkills: -(prevContribution?.publishedSkills ?? 0),
totalStars: -(prevContribution?.totalStars ?? 0),
totalDownloads: -(prevContribution?.totalDownloads ?? 0),
});
}
if (nextOwnerId) {
await patchUserStats(ctx, nextOwnerId, {
publishedSkills: nextContribution?.publishedSkills ?? 0,
totalStars: nextContribution?.totalStars ?? 0,
totalDownloads: nextContribution?.totalDownloads ?? 0,
});
}
}
+1 -5
View File
@@ -28,9 +28,6 @@ const users = defineTable({
githubFetchedAt: v.optional(v.number()),
githubProfileSyncedAt: v.optional(v.number()),
trustedPublisher: v.optional(v.boolean()),
publishedSkills: v.optional(v.number()),
totalStars: v.optional(v.number()),
totalDownloads: v.optional(v.number()),
personalPublisherId: v.optional(v.id("publishers")),
requiresModerationAt: v.optional(v.number()),
requiresModerationReason: v.optional(v.string()),
@@ -43,8 +40,7 @@ const users = defineTable({
})
.index("email", ["email"])
.index("phone", ["phone"])
.index("handle", ["handle"])
.index("by_active_handle", ["deletedAt", "deactivatedAt", "handle"]);
.index("handle", ["handle"]);
const publishers = defineTable({
kind: v.union(v.literal("user"), v.literal("org")),
-419
View File
@@ -1,419 +0,0 @@
import type { Id } from "./_generated/dataModel";
import { internalMutation } from "./functions";
const DEMO_SKILLS = [
{
slug: "mcp-github",
displayName: "MCP GitHub",
summary: "Full GitHub API integration via MCP — issues, PRs, repos, code search, and actions.",
downloads: 14200,
stars: 342,
installs: 8100,
},
{
slug: "claude-memory",
displayName: "Claude Memory",
summary: "Persistent memory layer for Claude — stores context across conversations with vector recall.",
downloads: 11800,
stars: 287,
installs: 6400,
},
{
slug: "web-scraper-pro",
displayName: "Web Scraper Pro",
summary: "Intelligent web scraping with automatic pagination, JS rendering, and structured data extraction.",
downloads: 9400,
stars: 198,
installs: 5200,
},
{
slug: "sql-analyst",
displayName: "SQL Analyst",
summary: "Natural language to SQL with schema introspection, query optimization, and result visualization.",
downloads: 8700,
stars: 221,
installs: 4800,
},
{
slug: "pytest-agent",
displayName: "Pytest Agent",
summary: "Automated test generation and execution for Python — coverage analysis, mutation testing, fixtures.",
downloads: 7200,
stars: 156,
installs: 3900,
},
{
slug: "docker-compose-helper",
displayName: "Docker Compose Helper",
summary: "Generate, validate, and debug Docker Compose configurations with multi-service orchestration.",
downloads: 6800,
stars: 134,
installs: 3600,
},
{
slug: "api-docs-generator",
displayName: "API Docs Generator",
summary: "Auto-generate OpenAPI specs and beautiful documentation from any codebase or endpoint.",
downloads: 5900,
stars: 178,
installs: 3100,
},
{
slug: "slack-bot-builder",
displayName: "Slack Bot Builder",
summary: "Build and deploy Slack bots with natural language — slash commands, modals, and event handlers.",
downloads: 5400,
stars: 112,
installs: 2800,
},
{
slug: "terraform-assistant",
displayName: "Terraform Assistant",
summary: "Infrastructure as code helper — plan reviews, drift detection, module generation for AWS/GCP/Azure.",
downloads: 4800,
stars: 145,
installs: 2400,
},
{
slug: "regex-wizard",
displayName: "Regex Wizard",
summary: "Natural language to regex with live testing, explanation, and edge case generation.",
downloads: 4200,
stars: 89,
installs: 2100,
},
{
slug: "git-history-explorer",
displayName: "Git History Explorer",
summary: "Semantic search through git history — find commits by intent, trace code evolution, blame analysis.",
downloads: 3900,
stars: 102,
installs: 1900,
},
{
slug: "cron-scheduler",
displayName: "Cron Scheduler",
summary: "Natural language to cron expressions with timezone handling, overlap protection, and monitoring.",
downloads: 3400,
stars: 67,
installs: 1600,
},
{
slug: "jwt-debugger",
displayName: "JWT Debugger",
summary: "Decode, verify, and generate JWTs with visual payload inspection and expiry tracking.",
downloads: 3100,
stars: 78,
installs: 1400,
},
{
slug: "graphql-builder",
displayName: "GraphQL Builder",
summary: "Schema-first GraphQL development — type generation, resolver scaffolding, and playground integration.",
downloads: 2800,
stars: 94,
installs: 1200,
},
{
slug: "security-scanner",
displayName: "Security Scanner",
summary: "OWASP-aware security scanning for codebases — dependency audit, secret detection, SAST patterns.",
downloads: 2500,
stars: 156,
installs: 1100,
},
{
slug: "markdown-slides",
displayName: "Markdown Slides",
summary: "Turn markdown into presentation decks with themes, speaker notes, and PDF export.",
downloads: 2200,
stars: 45,
installs: 900,
},
{
slug: "env-manager",
displayName: "Env Manager",
summary: "Environment variable management across projects — sync .env files, validate schemas, rotate secrets.",
downloads: 1800,
stars: 56,
installs: 800,
},
{
slug: "csv-transform",
displayName: "CSV Transform",
summary: "Powerful CSV/TSV manipulation — column transforms, joins, pivots, and format conversion.",
downloads: 1500,
stars: 34,
installs: 600,
},
{
slug: "ssh-config-manager",
displayName: "SSH Config Manager",
summary: "Manage SSH configs, keys, and tunnels with natural language — jump hosts, port forwarding, agent setup.",
downloads: 1200,
stars: 42,
installs: 500,
},
{
slug: "changelog-writer",
displayName: "Changelog Writer",
summary: "Generate changelogs from git history with conventional commit parsing and release note formatting.",
downloads: 980,
stars: 28,
installs: 400,
},
];
const DEMO_OWNERS = [
{ handle: "anthropic", displayName: "Anthropic", highlighted: true },
{ handle: "openai-labs", displayName: "OpenAI Labs", highlighted: false },
{ handle: "devtools-co", displayName: "DevTools Co", highlighted: false },
{ handle: "securityfirst", displayName: "SecurityFirst", highlighted: true },
{ handle: "dataflow", displayName: "DataFlow", highlighted: false },
];
export const seedDemoSkills = internalMutation({
args: {},
handler: async (ctx) => {
// Check if we already seeded
const existingSkill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", "mcp-github"))
.first();
if (existingSkill) {
return { seeded: false, reason: "already seeded" };
}
// Create a seed user
const seedUserId = await ctx.db.insert("users", {
name: "ClawHub Demo",
displayName: "ClawHub Demo",
handle: "clawhub-demo",
image: undefined,
role: "admin",
});
// Create publisher accounts
const publisherIds: string[] = [];
for (const owner of DEMO_OWNERS) {
const pubId = await ctx.db.insert("publishers", {
kind: "org",
handle: owner.handle,
displayName: owner.displayName,
linkedUserId: seedUserId,
createdAt: Date.now(),
updatedAt: Date.now(),
});
publisherIds.push(pubId);
// Add membership
await ctx.db.insert("publisherMembers", {
publisherId: pubId as Id<"publishers">,
userId: seedUserId,
role: "owner",
createdAt: Date.now(),
updatedAt: Date.now(),
});
}
const now = Date.now();
const DAY = 86400000;
let totalPublishedSkills = 0;
let totalStars = 0;
let totalDownloads = 0;
for (let i = 0; i < DEMO_SKILLS.length; i++) {
const s = DEMO_SKILLS[i];
const ownerIdx = i % publisherIds.length;
const createdDaysAgo = Math.floor(Math.random() * 90) + 7;
const updatedDaysAgo = Math.floor(Math.random() * createdDaysAgo);
const createdAt = now - createdDaysAgo * DAY;
const updatedAt = now - updatedDaysAgo * DAY;
const version = `${Math.floor(Math.random() * 3) + 1}.${Math.floor(Math.random() * 10)}.${Math.floor(Math.random() * 20)}`;
const isHighlighted = i < 6;
const badges = isHighlighted
? { highlighted: { byUserId: seedUserId, at: now } }
: undefined;
const numVersions = Math.floor(Math.random() * 8) + 1;
const numComments = Math.floor(Math.random() * 15);
// Create skill first (without latestVersionId)
const skillId = await ctx.db.insert("skills", {
slug: s.slug,
displayName: s.displayName,
summary: s.summary,
ownerUserId: seedUserId,
ownerPublisherId: publisherIds[ownerIdx] as Id<"publishers">,
tags: {},
badges,
moderationStatus: "active",
moderationVerdict: "clean",
stats: {
downloads: s.downloads,
installsCurrent: Math.floor(s.installs * 0.3),
installsAllTime: s.installs,
stars: s.stars,
versions: numVersions,
comments: numComments,
},
statsDownloads: s.downloads,
statsStars: s.stars,
statsInstallsCurrent: Math.floor(s.installs * 0.3),
statsInstallsAllTime: s.installs,
createdAt,
updatedAt,
});
// Create skillBadges entry for highlighted skills
if (isHighlighted) {
await ctx.db.insert("skillBadges", {
skillId,
kind: "highlighted",
byUserId: seedUserId,
at: now,
});
}
// Now create version with real skillId
const versionId = await ctx.db.insert("skillVersions", {
skillId,
version,
changelog: `Release ${version} — improvements and bug fixes.`,
files: [],
parsed: { frontmatter: {} },
createdBy: seedUserId,
createdAt: updatedAt,
});
// Patch skill with version info
await ctx.db.patch(skillId, {
latestVersionId: versionId,
latestVersionSummary: {
version,
createdAt: updatedAt,
changelog: `Release ${version}`,
},
tags: { latest: versionId },
});
totalPublishedSkills += 1;
totalStars += s.stars;
totalDownloads += s.downloads;
// Create digest for search
await ctx.db.insert("skillSearchDigest", {
skillId,
slug: s.slug,
displayName: s.displayName,
summary: s.summary,
ownerUserId: seedUserId,
ownerPublisherId: publisherIds[ownerIdx] as Id<"publishers">,
ownerHandle: DEMO_OWNERS[ownerIdx].handle,
ownerName: DEMO_OWNERS[ownerIdx].displayName,
ownerDisplayName: DEMO_OWNERS[ownerIdx].displayName,
ownerImage: undefined,
latestVersionId: versionId,
latestVersionSummary: {
version,
createdAt: updatedAt,
changelog: `Release ${version}`,
},
tags: { latest: versionId },
badges,
stats: {
downloads: s.downloads,
installsCurrent: Math.floor(s.installs * 0.3),
installsAllTime: s.installs,
stars: s.stars,
versions: numVersions,
comments: numComments,
},
versions: numVersions,
comments: numComments,
moderationReason: undefined,
isSuspicious: false,
createdAt,
updatedAt,
});
}
await ctx.db.patch(seedUserId, {
publishedSkills: totalPublishedSkills,
totalStars,
totalDownloads,
});
return { seeded: true, count: DEMO_SKILLS.length };
},
});
// Repair globalStats count to match actual seeded data
export const repairGlobalStats = internalMutation({
args: {},
handler: async (ctx) => {
// Count active digests — push filter server-side
const digests = await ctx.db
.query("skillSearchDigest")
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
.filter((q) => q.eq(q.field("moderationStatus"), "active"))
.collect();
const count = digests.length;
// Update globalStats
const stats = await ctx.db
.query("globalStats")
.filter((q) => q.eq(q.field("key"), "default"))
.first();
if (stats) {
await ctx.db.patch(stats._id, { activeSkillsCount: count, updatedAt: Date.now() });
} else {
await ctx.db.insert("globalStats", {
key: "default",
activeSkillsCount: count,
updatedAt: Date.now(),
});
}
return { count };
},
});
// Repair function to add missing skillBadges for already-seeded data
export const repairHighlightedBadges = internalMutation({
args: {},
handler: async (ctx) => {
const highlightedSlugs = DEMO_SKILLS.slice(0, 6).map((s) => s.slug);
let fixed = 0;
for (const slug of highlightedSlugs) {
const skill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.first();
if (!skill) continue;
// Check if badge already exists
const existing = await ctx.db
.query("skillBadges")
.withIndex("by_skill_kind", (q) =>
q.eq("skillId", skill._id).eq("kind", "highlighted"),
)
.first();
if (existing) continue;
await ctx.db.insert("skillBadges", {
skillId: skill._id,
kind: "highlighted",
byUserId: skill.ownerUserId,
at: Date.now(),
});
fixed++;
}
return { fixed };
},
});
-2
View File
@@ -23,7 +23,6 @@ import type { Doc, Id } from "./_generated/dataModel";
import type { MutationCtx } from "./_generated/server";
import { internalAction, internalMutation, internalQuery } from "./functions";
import { applySkillStatDeltas, bumpDailySkillStats } from "./lib/skillStats";
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
/**
* Event types that affect skill stats:
@@ -260,7 +259,6 @@ export const processSkillStatEventsInternal = internalMutation({
// Don't update `updatedAt` — stat changes shouldn't move the
// skill's position in the by_active_updated index.
await ctx.db.patch(skill._id, patch);
await adjustUserSkillStatsForSkillChange(ctx, skill, { ...skill, ...patch });
}
// NOTE: Daily stats (skillDailyStats) are written by the 15-minute
-8
View File
@@ -84,7 +84,6 @@ import {
extractDigestFields,
upsertSkillSearchDigest,
} from "./lib/skillSearchDigest";
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
import schema from "./schema";
export { publishVersionForUser } from "./lib/skillPublish";
@@ -759,7 +758,6 @@ async function hardDeleteSkillStep(
const nextSkill = { ...skill, ...patch };
await ctx.db.patch(skill._id, patch);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
}
switch (phase) {
@@ -2531,7 +2529,6 @@ export const report = mutation({
const nextSkill = { ...skill, ...updates };
await ctx.db.patch(skill._id, updates);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
if (shouldAutoHide) {
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, now);
@@ -4258,7 +4255,6 @@ export const applyBanToOwnedSkillsBatchInternal = internalMutation({
const nextSkill = { ...skill, ...patch };
await ctx.db.patch(skill._id, patch);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, args.bannedAt);
}
@@ -4370,7 +4366,6 @@ export const restoreOwnedSkillsForUnbanBatchInternal = internalMutation({
const nextSkill = { ...skill, ...patch };
await ctx.db.patch(skill._id, patch);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, false, now);
restoredCount += 1;
@@ -5310,7 +5305,6 @@ export const setSoftDeleted = mutation({
const nextSkill = { ...skill, ...patch };
await ctx.db.patch(skill._id, patch);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, args.deleted, now);
@@ -5349,7 +5343,6 @@ export const changeOwner = mutation({
lastReviewedAt: now,
updatedAt: now,
});
await adjustUserSkillStatsForSkillChange(ctx, skill, { ...skill, ownerUserId: args.ownerUserId });
const embeddings = await listSkillEmbeddingsForSkill(ctx, skill._id);
for (const embedding of embeddings) {
@@ -6430,7 +6423,6 @@ export const insertVersion = internalMutation({
// Digest sync is handled after the version patch below (line ~4222),
// which captures the final state including latestVersionId and tags.
await adjustGlobalPublicCountForSkillChange(ctx, null, skill);
await adjustUserSkillStatsForSkillChange(ctx, null, skill);
}
}
+12 -56
View File
@@ -3,15 +3,20 @@ import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
import { assertAdmin, assertModerator, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
import {
assertAdmin,
assertModerator,
getOptionalActiveAuthUserId,
requireUser,
} from "./lib/access";
import { syncGitHubProfile } from "./lib/githubAccount";
import { toPublicUser } from "./lib/public";
import {
ensurePersonalPublisherForUser,
getActiveUserByHandleOrPersonalPublisher,
getPublisherByHandle,
getUserByHandleOrPersonalPublisher,
} from "./lib/publishers";
import { toPublicUser } from "./lib/public";
import {
getLatestActiveReservedHandle,
isHandleReservedForAnotherUser,
@@ -296,7 +301,9 @@ export async function ensureHandler(ctx: MutationCtx) {
updates.updatedAt = Date.now();
await ctx.db.patch(userId, updates);
}
const ensuredUser = hasUpdates ? ({ ...user, ...updates } as Doc<"users">) : ((await ctx.db.get(userId)) ?? user);
const ensuredUser = hasUpdates
? ({ ...user, ...updates } as Doc<"users">)
: ((await ctx.db.get(userId)) ?? user);
await ensurePersonalPublisherForUser(ctx, ensuredUser);
return await ctx.db.get(userId);
}
@@ -386,23 +393,6 @@ export const list = query({
},
});
export const listPublic = query({
args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
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
.map((user) => toPublicUser(user))
.filter((user): user is NonNullable<ReturnType<typeof toPublicUser>> => Boolean(user)),
total: result.total,
};
},
});
function normalizeSearchQuery(search?: string) {
const trimmed = search?.trim().toLowerCase();
return trimmed ? trimmed : undefined;
@@ -435,27 +425,6 @@ async function queryUsersForAdminList(
};
}
async function queryUsersForPublicList(
ctx: Pick<QueryCtx, "db">,
args: { limit: number; search?: string },
) {
const normalizedSearch = normalizeSearchQuery(args.search);
const scanLimit = normalizedSearch
? computeUserSearchScanLimit(args.limit)
: clampInt(args.limit * 6, args.limit, MAX_USER_SEARCH_SCAN);
const scannedUsers = await ctx.db
.query("users")
.withIndex("by_active_handle", (q) => q.eq("deletedAt", undefined).eq("deactivatedAt", undefined))
.order("desc")
.take(scanLimit);
const activeUsers = scannedUsers.filter((user) => Boolean(user.handle));
const result = buildUserSearchResults(activeUsers, normalizedSearch);
return {
items: result.items.slice(0, args.limit),
total: result.total,
};
}
function clampInt(value: number, min: number, max: number) {
return Math.min(Math.max(Math.trunc(value), min), max);
}
@@ -467,20 +436,6 @@ export const getByHandle = query({
},
});
/** Lightweight stats for user hover tooltips. Uses the skills by_owner index. */
export const getHoverStats = query({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
return {
publishedSkills: user?.publishedSkills ?? 0,
totalStars: user?.totalStars ?? 0,
totalDownloads: user?.totalDownloads ?? 0,
};
},
});
export const getReservedHandleInternal = internalQuery({
args: { handle: v.string() },
handler: async (ctx, args) => {
@@ -899,7 +854,8 @@ async function ensurePublisherHandleWithActor(
if (existing) {
const nextDisplayName =
args.displayName?.trim() && (!existing.displayName || existing.displayName === existing.handle)
args.displayName?.trim() &&
(!existing.displayName || existing.displayName === existing.handle)
? displayName
: existing.displayName;
await ctx.db.patch(existing._id, {
-6
View File
@@ -46,8 +46,6 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -60,7 +58,6 @@
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "1.167.2",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clawhub-schema": "workspace:*",
"clsx": "^2.1.1",
"convex": "^1.34.1",
@@ -70,8 +67,6 @@
"ignore": "^7.0.5",
"lucide-react": "^0.577.0",
"monaco-editor": "^0.55.1",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"nitro": "3.0.260311-beta",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -82,7 +77,6 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"vite-tsconfig-paths": "^6.1.1",
"yaml": "^2.8.3",
"zod": "^4.3.6"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 28 KiB

-45
View File
@@ -1,45 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<rect width="512" height="512" rx="0" fill="none"/>
<!-- Body segments -->
<g fill="#F03020" stroke="#fff" stroke-width="6" stroke-linejoin="round">
<!-- Tail fan - center -->
<ellipse cx="256" cy="460" rx="42" ry="30"/>
<!-- Tail fan - left -->
<ellipse cx="210" cy="455" rx="32" ry="24" transform="rotate(25 210 455)"/>
<!-- Tail fan - right -->
<ellipse cx="302" cy="455" rx="32" ry="24" transform="rotate(-25 302 455)"/>
<!-- Lower body segment -->
<rect x="208" y="380" width="96" height="55" rx="12"/>
<!-- Mid-lower body -->
<rect x="204" y="335" width="104" height="55" rx="12"/>
<!-- Mid body -->
<rect x="200" y="290" width="112" height="55" rx="14"/>
<!-- Upper body / thorax -->
<ellipse cx="256" cy="255" rx="72" ry="65"/>
<!-- Head -->
<circle cx="256" cy="195" r="58"/>
<!-- Left claw arm -->
<path d="M195 230 Q150 190 120 170 Q100 155 95 140" stroke-width="18" fill="none" stroke-linecap="round"/>
<!-- Right claw arm -->
<path d="M317 230 Q362 190 392 170 Q412 155 417 140" stroke-width="18" fill="none" stroke-linecap="round"/>
<!-- Left claw -->
<ellipse cx="88" cy="115" rx="52" ry="42" transform="rotate(-20 88 115)"/>
<!-- Right claw -->
<ellipse cx="424" cy="115" rx="52" ry="42" transform="rotate(20 424 115)"/>
<!-- Left claw notch -->
<path d="M65 95 Q78 80 72 65" stroke-width="6" fill="none" stroke="#fff" stroke-linecap="round"/>
<!-- Right claw notch -->
<path d="M447 95 Q434 80 440 65" stroke-width="6" fill="none" stroke="#fff" stroke-linecap="round"/>
<!-- Left legs -->
<path d="M205 290 Q175 295 155 305" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M202 320 Q172 330 152 340" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M205 350 Q178 358 158 370" stroke-width="10" fill="none" stroke-linecap="round"/>
<!-- Right legs -->
<path d="M307 290 Q337 295 357 305" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M310 320 Q340 330 360 340" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M307 350 Q334 358 354 370" stroke-width="10" fill="none" stroke-linecap="round"/>
</g>
<!-- Eyes -->
<circle cx="236" cy="185" r="10" fill="#000"/>
<circle cx="276" cy="185" r="10" fill="#000"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

+16 -67
View File
@@ -1,25 +1,12 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import Header from "../components/Header";
const siteModeMock = vi.fn(() => "souls");
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: () => vi.fn(),
Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
}));
vi.mock("@convex-dev/auth/react", () => ({
@@ -29,30 +16,19 @@ vi.mock("@convex-dev/auth/react", () => ({
}),
}));
const authStatusMock = vi.fn(() => ({
isAuthenticated: false,
isLoading: false,
me: null,
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => authStatusMock(),
useAuthStatus: () => ({
isAuthenticated: false,
isLoading: false,
me: null,
}),
}));
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,
setMode: vi.fn(),
}),
}));
@@ -80,10 +56,14 @@ vi.mock("../lib/roles", () => ({
vi.mock("../lib/site", () => ({
getClawHubSiteUrl: () => "https://clawhub.ai",
getSiteMode: () => siteModeMock(),
getSiteMode: () => "souls",
getSiteName: () => "OnlyCrabs",
}));
vi.mock("../lib/convexError", () => ({
getUserFacingConvexError: vi.fn(),
}));
vi.mock("../lib/gravatar", () => ({
gravatarUrl: vi.fn(),
}));
@@ -98,46 +78,15 @@ vi.mock("../components/ui/dropdown-menu", () => ({
vi.mock("../components/ui/toggle-group", () => ({
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ToggleGroupItem: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
<button type="button">{children}</button>
),
}));
describe("Header", () => {
it("hides Packages navigation in soul mode on mobile and desktop", () => {
siteModeMock.mockReturnValue("souls");
render(<Header />);
expect(screen.queryByText("Packages")).toBeNull();
});
it("renders direct desktop theme family controls and plain Skills tab", () => {
siteModeMock.mockReturnValue("skills");
setThemeMock.mockClear();
setModeMock.mockClear();
render(<Header />);
expect(screen.getByText("Theme")).toBeTruthy();
expect(screen.getByRole("button", { name: "Claw" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Hub" })).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);
expect(screen.getAllByText("Souls")).toHaveLength(1);
expect(screen.getAllByText("Users")).toHaveLength(1);
expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Hub" }));
expect(setThemeMock).toHaveBeenCalledWith("hub");
fireEvent.click(screen.getByRole("button", { name: /Cycle theme family/i }));
expect(setThemeMock).toHaveBeenCalledWith("claw");
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
expect(setModeMock).toHaveBeenCalledWith("light");
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Souls")).toHaveLength(2);
expect(screen.getAllByText("Users")).toHaveLength(2);
});
});
-1
View File
@@ -17,7 +17,6 @@ const useAuthStatusMock = vi.fn();
let useActionCallCount = 0;
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useAction: () => {
const action = [previewImport, previewCandidate, importSkill][useActionCallCount % 3];
@@ -26,7 +26,6 @@ const useAuthStatusMock = vi.fn();
const originalFetch = globalThis.fetch;
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useMutation: () => generateUploadUrl,
useAction: () => publishRelease,
useQuery: () => undefined,
+72 -47
View File
@@ -1,74 +1,99 @@
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() },
}));
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: { validateSearch?: unknown; component?: unknown }) => ({
__config: config,
}),
createFileRoute: () => (config: { beforeLoad?: unknown }) => ({ __config: config }),
redirect: (options: unknown) => ({ redirect: options }),
Link: "a",
useNavigate: () => vi.fn(),
}));
import { Route } from "../routes/search";
function runValidateSearch(search: Record<string, unknown>) {
function runBeforeLoad(
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean },
hostname = "clawdhub.com",
) {
const route = Route as unknown as {
__config: {
validateSearch?: (search: Record<string, unknown>) => unknown;
beforeLoad?: (args: {
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean };
location: { url: URL };
}) => void;
};
};
const validateSearch = route.__config.validateSearch;
return validateSearch ? validateSearch(search) : {};
const beforeLoad = route.__config.beforeLoad as (args: {
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean };
location: { url: URL };
}) => void;
let thrown: unknown;
try {
beforeLoad({ search, location: { url: new URL(`https://${hostname}/search`) } });
} catch (error) {
thrown = error;
}
return thrown;
}
describe("search route", () => {
it("validates search with query", () => {
expect(runValidateSearch({ q: "crab" })).toEqual({
q: "crab",
type: undefined,
it("redirects skills host to the skills index", () => {
expect(runBeforeLoad({ q: "crab", highlighted: true }, "clawdhub.com")).toEqual({
redirect: {
to: "/skills",
search: {
q: "crab",
sort: undefined,
dir: undefined,
highlighted: true,
nonSuspicious: undefined,
view: undefined,
},
replace: true,
},
});
});
it("validates search with type filter", () => {
expect(runValidateSearch({ q: "crab", type: "skills" })).toEqual({
q: "crab",
type: "skills",
it("forwards nonSuspicious filter to skills index", () => {
expect(runBeforeLoad({ q: "crab", nonSuspicious: true }, "clawdhub.com")).toEqual({
redirect: {
to: "/skills",
search: {
q: "crab",
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
},
replace: true,
},
});
});
it("ignores invalid type filter", () => {
expect(runValidateSearch({ q: "crab", type: "invalid" })).toEqual({
q: "crab",
type: undefined,
it("redirects souls host with query to home search", () => {
expect(runBeforeLoad({ q: "crab", highlighted: true }, "onlycrabs.ai")).toEqual({
redirect: {
to: "/",
search: {
q: "crab",
highlighted: undefined,
search: undefined,
},
replace: true,
},
});
});
it("accepts the users type filter", () => {
expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({
q: "vincent",
type: "users",
it("redirects souls host without query to home with search mode", () => {
expect(runBeforeLoad({}, "onlycrabs.ai")).toEqual({
redirect: {
to: "/",
search: {
q: undefined,
highlighted: undefined,
search: true,
},
replace: true,
},
});
});
it("strips empty query", () => {
expect(runValidateSearch({ q: " " })).toEqual({
q: undefined,
type: undefined,
});
});
it("has a component (not a redirect-only route)", () => {
const route = Route as unknown as {
__config: { component?: unknown };
};
expect(route.__config.component).toBeDefined();
});
});
+194 -53
View File
@@ -6,18 +6,6 @@ import { SkillDetailPage } from "../components/SkillDetailPage";
const navigateMock = vi.fn();
const useAuthStatusMock = vi.fn();
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
vi.mock("../components/UserBadge", () => ({
UserBadge: () => null,
}));
vi.mock("../convex/client", () => ({
convex: {},
convexHttp: { query: vi.fn() },
}));
vi.mock("@tanstack/react-router", () => ({
Link: ({ children }: { children: unknown }) => children,
useNavigate: () => navigateMock,
@@ -27,7 +15,6 @@ const useQueryMock = vi.fn();
const getReadmeMock = vi.fn();
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => vi.fn(),
useAction: () => getReadmeMock,
@@ -37,8 +24,8 @@ vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
vi.mock("../components/SkillCommentsPanel", () => ({
SkillCommentsPanel: () => <div data-testid="skill-comments-panel" />,
vi.mock("../components/SkillDiffCard", () => ({
SkillDiffCard: () => <div data-testid="skill-diff-card" />,
}));
describe("SkillDetailPage", () => {
@@ -71,8 +58,11 @@ describe("SkillDetailPage", () => {
return undefined;
});
render(<SkillDetailPage slug="weather" />);
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
const { container } = render(<SkillDetailPage slug="weather" />);
// Loading state now renders a skeleton, not text
expect(
container.querySelector('[class*="animate-pulse"], [data-slot="skeleton"]'),
).toBeTruthy();
expect(screen.queryByText(/Skill not found/i)).toBeNull();
});
@@ -145,11 +135,174 @@ describe("SkillDetailPage", () => {
/>,
);
expect(screen.queryByText(/Loading skill/i)).toBeNull();
expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0);
// With initialData, should render content instead of skeleton
expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy();
expect(screen.getByText(/Get current weather\./i)).toBeTruthy();
expect(screen.getByRole("button", { name: "Files" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Compare" })).toBeNull();
expect(screen.getByRole("tab", { name: "Files" })).toBeTruthy();
});
it("shows capability tags on the skill page without other scan findings", async () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "skillId" in args) return [];
return undefined;
});
render(
<SkillDetailPage
slug="skill-pay"
initialData={{
result: {
skill: {
_id: skillId,
_creationTime: 0,
slug: "skill-pay",
displayName: "SkillPay",
summary: "Crypto payments for AI skills.",
ownerUserId: ownerId,
ownerPublisherId,
tags: {},
badges: {},
stats: {
stars: 12,
downloads: 34,
installsCurrent: 5,
installsAllTime: 8,
versions: 1,
comments: 0,
},
createdAt: 0,
updatedAt: 0,
},
owner: {
_id: ownerPublisherId,
_creationTime: 0,
kind: "user",
handle: "steipete",
displayName: "Peter",
linkedUserId: ownerId,
},
latestVersion: {
_id: versionId,
_creationTime: 0,
skillId,
version: "1.0.0",
fingerprint: "abc",
changelog: "Initial release",
parsed: { license: "MIT-0", frontmatter: {} },
capabilityTags: ["crypto", "requires-wallet", "can-make-purchases"],
sha256hash: "abc123",
files: [
{
path: "SKILL.md",
size: 10,
storageId,
sha256: "abc",
contentType: "text/markdown",
},
],
createdBy: ownerId,
createdAt: 0,
},
forkOf: null,
canonical: null,
},
readme: "# SkillPay",
readmeError: null,
}}
/>,
);
expect(await screen.findByRole("heading", { name: "SkillPay" })).toBeTruthy();
expect(screen.getByText("Capability signals")).toBeTruthy();
expect(screen.getByText("Crypto")).toBeTruthy();
expect(screen.getByText("Requires wallet")).toBeTruthy();
expect(screen.getByText("Can make purchases")).toBeTruthy();
});
it("prefers the full frontmatter description over the shortened summary in the header", async () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "skillId" in args) return [];
return undefined;
});
const fullDescription =
"Add credit-based payments to any OpenClaw skill. Register paid skills, charge users per call, track earnings, and withdraw USDC. Use when a user wants to monetize a skill.";
render(
<SkillDetailPage
slug="skill-pay"
initialData={{
result: {
skill: {
_id: skillId,
_creationTime: 0,
slug: "skill-pay",
displayName: "SkillPay",
summary: "Add credit-based payments to any OpenClaw skill. Register paid skills...",
ownerUserId: ownerId,
ownerPublisherId,
tags: {},
badges: {},
stats: {
stars: 12,
downloads: 34,
installsCurrent: 5,
installsAllTime: 8,
versions: 1,
comments: 0,
},
createdAt: 0,
updatedAt: 0,
},
owner: {
_id: ownerPublisherId,
_creationTime: 0,
kind: "user",
handle: "steipete",
displayName: "Peter",
linkedUserId: ownerId,
},
latestVersion: {
_id: versionId,
_creationTime: 0,
skillId,
version: "1.0.0",
fingerprint: "abc",
changelog: "Initial release",
parsed: {
license: "MIT-0",
frontmatter: {
description: fullDescription,
},
},
files: [
{
path: "SKILL.md",
size: 10,
storageId,
sha256: "abc",
contentType: "text/markdown",
},
],
createdBy: ownerId,
createdAt: 0,
},
forkOf: null,
canonical: null,
},
readme: "# SkillPay",
readmeError: null,
}}
/>,
);
expect(await screen.findByRole("heading", { name: "SkillPay" })).toBeTruthy();
// The header now always shows skill.summary (not frontmatter.description)
expect(
screen.getByText("Add credit-based payments to any OpenClaw skill. Register paid skills..."),
).toBeTruthy();
});
it("does not refetch readme when SSR data already matches the latest version", async () => {
@@ -222,7 +375,7 @@ describe("SkillDetailPage", () => {
/>,
);
expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0);
expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy();
expect(screen.getByText(/Get current weather\./i)).toBeTruthy();
expect(getReadmeMock).not.toHaveBeenCalled();
});
@@ -241,17 +394,17 @@ describe("SkillDetailPage", () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "skillId" in args) return [];
return {
skill: {
_id: "skills:1",
slug: "weather",
displayName: "Weather",
summary: "Get current weather.",
ownerUserId: "users:1",
ownerPublisherId: "publishers:steipete",
tags: {},
stats: { stars: 0, downloads: 0 },
},
return {
skill: {
_id: "skills:1",
slug: "weather",
displayName: "Weather",
summary: "Get current weather.",
ownerUserId: "users:1",
ownerPublisherId: "publishers:steipete",
tags: {},
stats: { stars: 0, downloads: 0 },
},
owner: {
_id: "publishers:steipete",
_creationTime: 0,
@@ -264,8 +417,9 @@ describe("SkillDetailPage", () => {
};
});
render(<SkillDetailPage slug="weather" redirectToCanonical />);
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
const { container } = render(<SkillDetailPage slug="weather" redirectToCanonical />);
// Loading state now renders a skeleton, not text
expect(container.querySelector('[class*="animate-pulse"]')).toBeTruthy();
await waitFor(() => {
expect(navigateMock).toHaveBeenCalled();
@@ -370,7 +524,7 @@ describe("SkillDetailPage", () => {
/>,
);
expect(screen.queryByText(/Loading skill/i)).toBeNull();
expect(screen.queryByText(/Skill not found/i)).toBeNull();
expect(screen.getAllByText("Weather").length).toBeGreaterThan(0);
expect(navigateMock).not.toHaveBeenCalled();
});
@@ -480,24 +634,10 @@ describe("SkillDetailPage", () => {
it("defers compare version query until compare tab is requested", async () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (
args &&
typeof args === "object" &&
"skillId" in args &&
"limit" in args &&
(args as { limit: number }).limit === 50
) {
return [
{ _id: "skillVersions:1", version: "1.0.0", files: [] },
{ _id: "skillVersions:2", version: "1.1.0", files: [] },
];
}
if (args && typeof args === "object" && "skillId" in args && "limit" in args) {
if ((args as { limit: number }).limit === 200) return [];
}
if (args && typeof args === "object" && "limit" in args) {
return [];
}
if (args && typeof args === "object" && "skillId" in args) return [];
if (args && typeof args === "object" && "slug" in args) {
return {
skill: {
@@ -526,7 +666,6 @@ describe("SkillDetailPage", () => {
render(<SkillDetailPage slug="weather" />);
expect(await screen.findByText("Weather")).toBeTruthy();
expect(screen.getByRole("button", { name: /compare/i })).toBeTruthy();
expect(
useQueryMock.mock.calls.some((call) => {
@@ -540,7 +679,9 @@ describe("SkillDetailPage", () => {
}),
).toBe(false);
fireEvent.click(screen.getByRole("button", { name: /compare/i }));
const compareTab = screen.getByRole("tab", { name: /compare/i });
fireEvent.mouseEnter(compareTab);
fireEvent.click(compareTab);
await waitFor(() => {
expect(
-22
View File
@@ -17,7 +17,6 @@ vi.mock("@tanstack/react-router", () => ({
createFileRoute:
() =>
(config: {
beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown;
loader?: (args: { params: { owner: string; slug: string } }) => Promise<unknown>;
component?: unknown;
head?: unknown;
@@ -32,7 +31,6 @@ vi.mock("../lib/skillPage", () => ({
async function loadRoute() {
return (await import("../routes/$owner/$slug")).Route as unknown as {
__config: {
beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown;
loader?: (args: { params: { owner: string; slug: string } }) => Promise<unknown>;
head?: (args: {
params: { owner: string; slug: string };
@@ -47,14 +45,6 @@ async function loadRoute() {
};
}
async function runBeforeLoad(params: { owner: string; slug: string }) {
const route = await loadRoute();
const beforeLoad = route.__config.beforeLoad as ((args: {
params: { owner: string; slug: string };
}) => unknown) | undefined;
return beforeLoad?.({ params });
}
async function runLoader(params: { owner: string; slug: string }) {
const route = await loadRoute();
const loader = route.__config.loader as (args: {
@@ -81,18 +71,6 @@ function runHead(
}
describe("skill route loader", () => {
it("allows numeric owner handles in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "123abc", slug: "weather" })).not.toThrow();
});
it("allows raw owner ids in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "users:abc123", slug: "weather" })).not.toThrow();
});
it("allows raw publisher ids in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "publishers:abc123", slug: "weather" })).not.toThrow();
});
beforeEach(() => {
fetchSkillPageDataMock.mockReset();
});
@@ -23,7 +23,6 @@ vi.mock("@tanstack/react-router", () => ({
}));
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useAction: (...args: unknown[]) => convexReactMocks.useAction(...args),
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
}));
+48 -16
View File
@@ -23,7 +23,6 @@ vi.mock("@tanstack/react-router", () => ({
}));
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useAction: (...args: unknown[]) => convexReactMocks.useAction(...args),
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
}));
@@ -67,7 +66,7 @@ describe("SkillsIndex", () => {
it("renders an empty state when no skills are returned", async () => {
render(<SkillsIndex />);
await act(async () => {});
expect(screen.getByText("No skills found")).toBeTruthy();
expect(screen.getByText("No skills match that filter")).toBeTruthy();
});
it("shows loading state before fetch completes", async () => {
@@ -75,9 +74,9 @@ describe("SkillsIndex", () => {
convexHttpMock.query.mockReturnValue(new Promise(() => {}));
render(<SkillsIndex />);
await act(async () => {});
// Results area shows skeleton or dash while loading
expect(screen.getByText("\u2014")).toBeTruthy();
expect(screen.queryByText("No skills found")).toBeNull();
// Header subtitle shows "Loading skills..."
expect(screen.getAllByText("Loading skills...").length).toBeGreaterThanOrEqual(1);
expect(screen.queryByText("No skills match that filter")).toBeNull();
});
it("shows empty state immediately when search returns no results", async () => {
@@ -92,8 +91,8 @@ describe("SkillsIndex", () => {
});
// Should show empty state, not loading
expect(screen.getByText("No skills found")).toBeTruthy();
expect(screen.queryByText(/Loading skills/)).toBeNull();
expect(screen.getByText("No skills match that filter")).toBeTruthy();
expect(screen.queryByText("Loading skills...")).toBeNull();
});
it("skips list fetch and calls search when query is set", async () => {
@@ -137,7 +136,7 @@ describe("SkillsIndex", () => {
render(<SkillsIndex />);
const input = screen.getByPlaceholderText("Search skills...");
const input = screen.getByPlaceholderText("Search skills by name, slug, or summary...");
await act(async () => {
fireEvent.change(input, { target: { value: "cli-design-framework" } });
await vi.runAllTimersAsync();
@@ -162,7 +161,7 @@ describe("SkillsIndex", () => {
render(<SkillsIndex />);
const input = screen.getByPlaceholderText("Search skills...");
const input = screen.getByPlaceholderText("Search skills by name, slug, or summary...");
await act(async () => {
fireEvent.change(input, { target: { value: "cli-design-framework" } });
await vi.runAllTimersAsync();
@@ -252,12 +251,9 @@ describe("SkillsIndex", () => {
await vi.runAllTimersAsync();
});
const titles = Array.from(
document.querySelectorAll(".skill-list-item-name"),
).map((node) => node.textContent);
expect(titles[0]).toBe("Older High Score");
expect(titles[1]).toBe("Newer Low Score");
const links = screen.getAllByRole("link");
expect(links[0]?.textContent).toContain("Older High Score");
expect(links[1]?.textContent).toContain("Newer Low Score");
});
it("passes nonSuspiciousOnly to list query when filter is active", async () => {
@@ -292,6 +288,42 @@ describe("SkillsIndex", () => {
);
});
it("passes capabilityTag to list query when tag filter is active", async () => {
searchMock = { tag: "crypto" };
render(<SkillsIndex />);
await act(async () => {});
expect(convexHttpMock.query).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
capabilityTag: "crypto",
}),
);
});
it("shows and clears the active capability tag filter", async () => {
searchMock = { tag: "crypto" };
render(<SkillsIndex />);
await act(async () => {});
const capabilityChip = screen.getByRole("button", { name: /crypto/i });
expect(capabilityChip).toBeTruthy();
await act(async () => {
fireEvent.click(capabilityChip);
});
expect(navigateMock).toHaveBeenCalled();
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
replace?: boolean;
search: (prev: Record<string, unknown>) => Record<string, unknown>;
};
expect(lastCall.replace).toBe(true);
expect(lastCall.search({ tag: "crypto" })).toEqual({
tag: undefined,
});
});
it("shows load-more button when more results are available", async () => {
vi.stubGlobal("IntersectionObserver", undefined);
convexHttpMock.query.mockResolvedValue({
@@ -324,7 +356,7 @@ describe("SkillsIndex", () => {
fireEvent.click(loadMoreButton);
});
expect(screen.getByText(/Loading/)).toBeTruthy();
expect(screen.getByRole("button", { name: "Load more" }).hasAttribute("disabled")).toBe(true);
});
});
+50
View File
@@ -0,0 +1,50 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { createRef, type ComponentProps } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { SkillsToolbar } from '../routes/skills/-SkillsToolbar';
function renderToolbar(overrides?: Partial<ComponentProps<typeof SkillsToolbar>>) {
return render(
<SkillsToolbar
searchInputRef={createRef<HTMLInputElement>()}
query=""
hasQuery={false}
sort="downloads"
dir="desc"
view="list"
highlightedOnly={false}
nonSuspiciousOnly={false}
capabilityTag={undefined}
onQueryChange={vi.fn()}
onToggleHighlighted={vi.fn()}
onToggleNonSuspicious={vi.fn()}
onCapabilityTagChange={vi.fn()}
onSortChange={vi.fn()}
onToggleDir={vi.fn()}
onToggleView={vi.fn()}
{...overrides}
/>,
);
}
describe('SkillsToolbar', () => {
it('keeps filter chips on a dark-mode surface', () => {
renderToolbar();
const staffPicksButton = screen.getByRole('button', { name: 'Staff Picks' });
expect(staffPicksButton.className).toContain('dark:bg-[rgba(14,28,37,0.84)]');
expect(staffPicksButton.className).toContain('dark:text-[rgba(245,238,232,0.88)]');
});
it('uses a readable active color treatment in dark mode', () => {
renderToolbar({ highlightedOnly: true });
const staffPicksButton = screen.getByRole('button', { name: 'Staff Picks' });
expect(staffPicksButton.getAttribute('aria-pressed')).toBe('true');
expect(staffPicksButton.className).toContain('dark:bg-[rgba(255,131,95,0.14)]');
expect(staffPicksButton.className).toContain('dark:text-[#ffd5c9]');
});
});
-1
View File
@@ -22,7 +22,6 @@ const useAuthStatusMock = vi.fn();
let useActionCallCount = 0;
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => generateUploadUrl,
useAction: () => {
+4 -7
View File
@@ -3,7 +3,6 @@ import { useEffect, useRef } from "react";
import { convex } from "../convex/client";
import { getUserFacingAuthError, normalizeAuthErrorMessage } from "../lib/authErrorMessage";
import { clearAuthError, setAuthError } from "../lib/useAuthError";
import { TooltipProvider } from "./ui/tooltip";
import { UserBootstrap } from "./UserBootstrap";
function getPendingAuthCode() {
@@ -83,12 +82,10 @@ export function AuthErrorHandler() {
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<ConvexAuthProvider client={convex} shouldHandleCode={false}>
<TooltipProvider delayDuration={400}>
<AuthCodeHandler />
<AuthErrorHandler />
<UserBootstrap />
{children}
</TooltipProvider>
<AuthCodeHandler />
<AuthErrorHandler />
<UserBootstrap />
{children}
</ConvexAuthProvider>
);
}
-120
View File
@@ -1,120 +0,0 @@
import {
Database,
GitBranch,
MessageSquare,
Package,
Plug,
Shield,
Wrench,
Zap,
} from "lucide-react";
import type { SkillCategory } from "../lib/categories";
type FilterItem = {
key: string;
label: string;
active: boolean;
};
type SortOption = {
value: string;
label: string;
};
type BrowseSidebarProps = {
categories?: SkillCategory[];
activeCategory?: string;
onCategoryChange?: (slug: string | undefined) => void;
sortOptions: SortOption[];
activeSort: string;
onSortChange: (value: string) => void;
filters: FilterItem[];
onFilterToggle: (key: string) => void;
};
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
"mcp-tools": <Plug size={15} />,
prompts: <MessageSquare size={15} />,
workflows: <GitBranch size={15} />,
"dev-tools": <Wrench size={15} />,
data: <Database size={15} />,
security: <Shield size={15} />,
automation: <Zap size={15} />,
other: <Package size={15} />,
};
export function BrowseSidebar({
categories,
activeCategory,
onCategoryChange,
sortOptions,
activeSort,
onSortChange,
filters,
onFilterToggle,
}: BrowseSidebarProps) {
return (
<aside className="browse-sidebar" aria-label="Browse filters">
<fieldset className="sidebar-section" role="radiogroup" aria-label="Sort order">
<legend className="sidebar-title">Sort by</legend>
{sortOptions.map((opt) => (
<button
key={opt.value}
className={`sidebar-option${activeSort === opt.value ? " is-active" : ""}`}
type="button"
role="radio"
aria-checked={activeSort === opt.value}
onClick={() => onSortChange(opt.value)}
>
{opt.label}
</button>
))}
</fieldset>
{categories && onCategoryChange ? (
<fieldset className="sidebar-section" role="radiogroup" aria-label="Category filter">
<legend className="sidebar-title">Categories</legend>
<button
className={`sidebar-option${!activeCategory ? " is-active" : ""}`}
type="button"
role="radio"
aria-checked={!activeCategory}
onClick={() => onCategoryChange(undefined)}
>
All
</button>
{categories.map((cat) => (
<button
key={cat.slug}
className={`sidebar-option${activeCategory === cat.slug ? " is-active" : ""}`}
type="button"
role="radio"
aria-checked={activeCategory === cat.slug}
onClick={() => onCategoryChange(cat.slug)}
>
<span className="sidebar-option-icon" aria-hidden="true">
{CATEGORY_ICONS[cat.slug]}
</span>
{cat.label}
</button>
))}
</fieldset>
) : null}
<fieldset className="sidebar-section" aria-label="Toggle filters">
<legend className="sidebar-title">Filters</legend>
{filters.map((f) => (
<label key={f.key} className="sidebar-checkbox">
<input
type="checkbox"
checked={f.active}
onChange={() => onFilterToggle(f.key)}
aria-label={f.label}
/>
<span>{f.label}</span>
</label>
))}
</fieldset>
</aside>
);
}
+11 -1
View File
@@ -69,7 +69,17 @@ function DeploymentDriftBannerContent() {
return (
<div
role="alert"
className="mx-auto mt-4 w-[min(1100px,calc(100vw-32px))] rounded-[14px] border border-status-warning-fg/40 bg-status-warning-bg px-4 py-3 text-[0.95rem] leading-[1.4] text-status-warning-fg"
style={{
margin: "16px auto 0",
width: "min(1100px, calc(100vw - 32px))",
border: "1px solid #f59e0b",
background: "#fff7ed",
color: "#9a3412",
borderRadius: "14px",
padding: "12px 16px",
fontSize: "0.95rem",
lineHeight: 1.4,
}}
>
Deploy mismatch detected. Frontend expects backend build <code>{drift.expectedBuildSha}</code>{" "}
but Convex reports <code>{drift.actualBuildSha}</code>.
+24 -41
View File
@@ -1,51 +1,34 @@
import { Link } from "@tanstack/react-router";
import { FOOTER_NAV_SECTIONS } from "../lib/nav-items";
import { getSiteName } from "../lib/site";
import { Separator } from "./ui/separator";
export function Footer() {
const siteName = getSiteName();
return (
<footer className="site-footer" role="contentinfo">
<div className="site-footer-inner">
<div className="site-footer-divider" aria-hidden="true" />
<div className="footer-grid">
{FOOTER_NAV_SECTIONS.map((section) => (
<div key={section.title} className="footer-col">
<h4 className="footer-col-title">{section.title}</h4>
{section.items.map((item) => {
if (item.kind === "link") {
return (
<Link key={item.label} to={item.to} search={item.search ?? {}}>
{item.label}
</Link>
);
}
if (item.kind === "external") {
return (
<a key={item.label} href={item.href} target="_blank" rel="noreferrer">
{item.label}
</a>
);
}
// kind === "text"
return <span key={item.label}>{item.label}</span>;
})}
</div>
))}
</div>
<div className="footer-bottom">
<span>
{siteName} An{" "}
<a href="https://openclaw.ai" target="_blank" rel="noreferrer">
OpenClaw
</a>{" "}
project by{" "}
<a href="https://steipete.me" target="_blank" rel="noreferrer">
Peter Steinberger
</a>
</span>
<footer className="mt-auto px-7 pb-8 pt-12">
<div className="mx-auto max-w-[1200px]">
<Separator className="mb-6" />
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[0.82rem] text-[color:var(--ink-soft)]">
<span className="font-semibold text-[color:var(--ink)]">{siteName}</span>
<FooterLink href="https://openclaw.ai">OpenClaw</FooterLink>
<FooterLink href="https://vercel.com">Vercel</FooterLink>
<FooterLink href="https://www.convex.dev">Convex</FooterLink>
<FooterLink href="https://github.com/openclaw/clawhub">Open source (MIT)</FooterLink>
<FooterLink href="https://steipete.me">Peter Steinberger</FooterLink>
</div>
</div>
</footer>
);
}
function FooterLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-[color:var(--ink-soft)] transition-colors duration-150 hover:text-[color:var(--ink)]"
>
{children}
</a>
);
}
+264 -404
View File
@@ -1,21 +1,16 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { Ghost, Github, Menu, Monitor, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { type ComponentType, useMemo, useRef, useState } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import { Link } from "@tanstack/react-router";
import { Menu, Monitor, Moon, Plus, Search, Sun } from "lucide-react";
import { useMemo, useRef } from "react";
import { gravatarUrl } from "../lib/gravatar";
import {
filterNavItems,
type NavIconName,
PRIMARY_NAV_ITEMS,
SECONDARY_NAV_ITEMS,
} from "../lib/nav-items";
import { isModerator } from "../lib/roles";
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
import { applyTheme, THEME_OPTIONS, useThemeMode } from "../lib/theme";
import { applyTheme, useThemeMode } from "../lib/theme";
import { startThemeTransition } from "../lib/theme-transition";
import { setAuthError, useAuthError } from "../lib/useAuthError";
import { useAuthError } from "../lib/useAuthError";
import { SignInButton } from "./SignInButton";
import { useAuthStatus } from "../lib/useAuthStatus";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Button } from "./ui/button";
import {
DropdownMenu,
@@ -24,444 +19,309 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "./ui/sheet";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet";
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?: string }>> = {
wrench: Wrench,
plug: Plug,
ghost: Ghost,
};
const THEME_FAMILY_ICONS: Record<string, ComponentType<{ size?: number; className?: string }>> = {
claw: Ghost,
hub: Plug,
};
const THEME_MODE_SEQUENCE: Array<"system" | "light" | "dark"> = ["system", "light", "dark"];
export default function Header() {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const { signIn, signOut } = useAuthActions();
const { theme, mode, setMode, setTheme } = useThemeMode();
const { signOut } = useAuthActions();
const { mode, setMode } = useThemeMode();
const toggleRef = useRef<HTMLDivElement | null>(null);
const siteMode = getSiteMode();
const siteName = useMemo(() => getSiteName(siteMode), [siteMode]);
const isSoulMode = siteMode === "souls";
const clawHubUrl = getClawHubSiteUrl();
const navigate = useNavigate();
const location = useLocation();
const avatar = me?.image ?? (me?.email ? gravatarUrl(me.email) : undefined);
const handle = me?.handle ?? me?.displayName ?? "user";
const initial = (me?.displayName ?? me?.name ?? handle).charAt(0).toUpperCase();
const isStaff = isModerator(me);
const hasResolvedUser = Boolean(me);
const navCtx = useMemo(
() => ({ isSoulMode, isAuthenticated: hasResolvedUser, isStaff }),
[hasResolvedUser, isSoulMode, isStaff],
);
const primaryItems = useMemo(() => filterNavItems(PRIMARY_NAV_ITEMS, navCtx), [navCtx]);
const secondaryItems = useMemo(() => filterNavItems(SECONDARY_NAV_ITEMS, navCtx), [navCtx]);
const { error: authError, clear: clearAuthError } = useAuthError();
const signInRedirectTo = getCurrentRelativeUrl();
const [navSearchQuery, setNavSearchQuery] = useState("");
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const themeLabel = THEME_OPTIONS.find((option) => option.value === theme)?.label ?? "Claw";
const ThemeFamilyIcon = THEME_FAMILY_ICONS[theme] ?? Wrench;
const ThemeModeIcon = getThemeModeIcon(mode);
const setThemeMode = (next: "system" | "light" | "dark") => {
const setTheme = (next: "system" | "light" | "dark") => {
startThemeTransition({
nextTheme: next,
currentTheme: mode,
setTheme: (value) => {
const nextMode = value as "system" | "light" | "dark";
applyTheme(nextMode, theme);
applyTheme(nextMode);
setMode(nextMode);
},
context: { element: toggleRef.current },
});
};
const setThemeFamily = (nextTheme: string) => {
applyTheme(mode, nextTheme);
setTheme(nextTheme);
};
const cycleThemeFamily = () => {
const currentIndex = Math.max(
0,
THEME_OPTIONS.findIndex((option) => option.value === theme),
);
const nextTheme = THEME_OPTIONS[(currentIndex + 1) % THEME_OPTIONS.length]?.value ?? "claw";
setThemeFamily(nextTheme);
};
const cycleThemeMode = () => {
const currentIndex = Math.max(0, THEME_MODE_SEQUENCE.indexOf(mode));
const nextMode = THEME_MODE_SEQUENCE[(currentIndex + 1) % THEME_MODE_SEQUENCE.length] ?? "system";
setThemeMode(nextMode);
};
const handleNavSearch = (e: React.FormEvent) => {
e.preventDefault();
const q = navSearchQuery.trim();
if (!q) return;
void navigate({
to: "/search",
search: { q, type: undefined },
});
setNavSearchQuery("");
setMobileSearchOpen(false);
};
const navLinks = (
<>
{isSoulMode ? (
<a
href={clawHubUrl}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
ClawHub
</a>
) : null}
{isSoulMode ? (
<Link
to="/souls"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Souls
</Link>
) : (
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Skills
</Link>
)}
{isSoulMode ? null : (
<Link
to="/plugins"
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Plugins
</Link>
)}
<Link
to={isSoulMode ? "/souls" : "/skills"}
search={
isSoulMode
? { q: undefined, sort: undefined, dir: undefined, view: undefined, focus: "search" }
: {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: "search",
}
}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)] inline-flex items-center gap-1.5"
>
<Search className="h-3.5 w-3.5" />
Search
</Link>
{isSoulMode ? null : (
<Link
to="/about"
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
About
</Link>
)}
{me ? (
<Link
to="/stars"
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Stars
</Link>
) : null}
{isStaff ? (
<Link
to="/management"
search={{ skill: undefined }}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Management
</Link>
) : null}
</>
);
return (
<header className="navbar">
<div className="navbar-inner">
{/* Row 1: Brand + Search + Actions */}
<div className="navbar-top">
<div className="nav-mobile">
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<button
className="nav-mobile-trigger"
type="button"
aria-label="Open menu"
onClick={() => setMobileMenuOpen(true)}
>
<Menu className="h-4 w-4" aria-hidden="true" />
</button>
<SheetContent side="left" className="mobile-nav-sheet">
<SheetHeader className="pr-10">
<header className="sticky top-0 z-50 border-b border-[color:var(--line)] bg-[color:var(--nav-bg)] backdrop-blur-xl">
<div className="mx-auto flex h-16 max-w-[1200px] items-center justify-between gap-4 px-5">
{/* Brand */}
<Link
to="/"
search={{ q: undefined, highlighted: undefined, search: undefined }}
className="flex items-center gap-2.5 font-display text-lg font-bold text-[color:var(--ink)] no-underline transition-opacity hover:opacity-80"
>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] p-0.5">
<img
src="/clawd-logo.png"
alt=""
aria-hidden="true"
className="h-full w-full rounded-full object-cover"
/>
</span>
<span>{siteName}</span>
</Link>
{/* Desktop nav */}
<nav className="hidden items-center gap-6 md:flex">{navLinks}</nav>
{/* Actions */}
<div className="flex items-center gap-3">
{/* Publish CTA (desktop, authenticated) */}
{isAuthenticated && me && (
<Link
to="/publish-skill"
search={{ updateSlug: undefined }}
className="hidden sm:block"
>
<Button variant="primary" size="sm">
<Plus className="h-3.5 w-3.5" />
Publish
</Button>
</Link>
)}
{/* Mobile nav trigger */}
<div className="md:hidden">
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Open menu">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-72">
<SheetHeader>
<SheetTitle>{siteName}</SheetTitle>
<SheetDescription>
Browse sections, switch theme, and access account actions.
</SheetDescription>
</SheetHeader>
<div className="mobile-nav-section">
{isSoulMode ? (
<SheetClose asChild>
<a href={clawHubUrl} className="mobile-nav-link">
ClawHub
</a>
</SheetClose>
) : null}
{primaryItems.map((item) => (
<SheetClose key={item.to + item.label} asChild>
<Link to={item.to} search={item.search ?? {}} className="mobile-nav-link">
{item.label}
</Link>
</SheetClose>
))}
{secondaryItems.map((item) => (
<SheetClose key={item.to + item.label} asChild>
<Link to={item.to} search={item.search ?? {}} className="mobile-nav-link">
{item.label === "Management" ? "Manage" : item.label}
</Link>
</SheetClose>
))}
</div>
<div className="mobile-nav-section">
<div className="mobile-nav-section-title">Theme family</div>
{THEME_OPTIONS.map((option) => (
<button
key={option.value}
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeFamily(option.value);
setMobileMenuOpen(false);
}}
>
<span>{option.label}</span>
{theme === option.value ? <span className="mobile-nav-meta">Selected</span> : null}
</button>
))}
</div>
<div className="mobile-nav-section">
<div className="mobile-nav-section-title">Theme mode</div>
<button
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeMode("system");
setMobileMenuOpen(false);
<nav className="mt-6 flex flex-col gap-4">{navLinks}</nav>
{/* Mobile theme toggle */}
<div className="mt-6 flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-widest text-[color:var(--ink-soft)]">
Theme
</span>
<ToggleGroup
type="single"
value={mode}
onValueChange={(value) => {
if (!value) return;
setTheme(value as "system" | "light" | "dark");
}}
aria-label="Theme mode"
>
<Monitor className="h-4 w-4" aria-hidden="true" />
System
</button>
<button
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeMode("light");
setMobileMenuOpen(false);
}}
>
<Sun className="h-4 w-4" aria-hidden="true" />
Light
</button>
<button
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeMode("dark");
setMobileMenuOpen(false);
}}
>
<Moon className="h-4 w-4" aria-hidden="true" />
Dark
</button>
<ToggleGroupItem value="system" aria-label="System theme">
<Monitor className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
<ToggleGroupItem value="light" aria-label="Light theme">
<Sun className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark theme">
<Moon className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Mobile publish link */}
{isAuthenticated && me && (
<div className="mt-6">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Button variant="primary" className="w-full">
<Plus className="h-4 w-4" />
Publish Skill
</Button>
</Link>
</div>
)}
</SheetContent>
</Sheet>
</div>
<Link
to="/"
search={{ q: undefined, highlighted: undefined, search: undefined }}
className="brand"
>
<span className="brand-mark">
<img src="/clawd-logo.png" alt="" aria-hidden="true" className="brand-mark-image" />
</span>
<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="nav-actions">
<button
className="navbar-search-mobile-trigger"
type="button"
aria-label="Search"
onClick={() => setMobileSearchOpen(!mobileSearchOpen)}
{/* Desktop theme toggle */}
<div className="theme-toggle hidden md:block" ref={toggleRef}>
<ToggleGroup
type="single"
value={mode}
onValueChange={(value) => {
if (!value) return;
setTheme(value as "system" | "light" | "dark");
}}
aria-label="Theme mode"
>
<Search size={18} aria-hidden="true" />
</button>
<div className="theme-toggle" ref={toggleRef}>
<div className="theme-picker-desktop" aria-label={`Theme family, current ${themeLabel}`}>
<div className="theme-family-toggle" role="group" aria-label="Theme family">
{THEME_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className="theme-family-button"
data-state={theme === option.value ? "on" : "off"}
aria-pressed={theme === option.value}
onClick={() => setThemeFamily(option.value)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="theme-cycle-group" aria-label="Theme controls">
<ToggleGroupItem value="system" aria-label="System theme">
<Monitor className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
<ToggleGroupItem value="light" aria-label="Light theme">
<Sun className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark theme">
<Moon className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* User menu / Sign in */}
{isAuthenticated && me ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="theme-cycle-button theme-cycle-button-family"
onClick={cycleThemeFamily}
aria-label={`Cycle theme family. Current: ${themeLabel}`}
title={`Theme family: ${themeLabel}`}
className="flex cursor-pointer items-center gap-2 rounded-full border border-[color:var(--line)] bg-[color:var(--surface)] px-2 py-1.5 text-sm font-semibold text-[color:var(--ink)] transition-colors hover:border-[color:var(--border-ui-hover)]"
>
<ThemeFamilyIcon className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
className="theme-cycle-button theme-cycle-button-mode"
onClick={cycleThemeMode}
aria-label={`Cycle theme mode. Current: ${mode}`}
title={`Theme mode: ${mode}`}
>
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<ToggleGroup
className="theme-mode-toggle"
type="single"
value={mode}
onValueChange={(value) => {
if (!value) return;
setThemeMode(value as "system" | "light" | "dark");
}}
aria-label={`Theme mode, ${themeLabel} preset`}
>
<ToggleGroupItem value="system" aria-label="System theme">
<Monitor className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">System</span>
</ToggleGroupItem>
<ToggleGroupItem value="light" aria-label="Light theme">
<Sun className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Light</span>
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark theme">
<Moon className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Dark</span>
</ToggleGroupItem>
</ToggleGroup>
</div>
{isAuthenticated && me ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="user-trigger" type="button">
{avatar ? (
<img src={avatar} alt={me.displayName ?? me.name ?? "User avatar"} />
) : (
<span className="user-menu-fallback">{initial}</span>
<Avatar className="h-7 w-7">
{avatar && (
<AvatarImage src={avatar} alt={me.displayName ?? me.name ?? "User avatar"} />
)}
<span className="mono">@{handle}</span>
<span className="user-menu-chevron"></span>
<AvatarFallback className="text-xs">{initial}</AvatarFallback>
</Avatar>
<span className="hidden font-mono text-xs sm:inline">@{handle}</span>
<span className="text-xs text-[color:var(--ink-soft)]"></span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to="/dashboard">Dashboard</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings">Settings</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void signOut()}>Sign out</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<>
{authError ? (
<div
className="flex items-center gap-1 text-[0.85rem] text-red-600 dark:text-red-400"
role="alert"
>
{authError}
<button
type="button"
onClick={clearAuthError}
aria-label="Dismiss"
className="ml-1 cursor-pointer border-none bg-transparent p-0.5 text-inherit opacity-70 hover:opacity-100"
>
&times;
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to="/dashboard">Dashboard</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings">Settings</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void signOut()}>Sign out</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<>
{authError ? (
<div className="error mr-2 text-[0.85rem]" role="alert">
{authError}{" "}
<button
type="button"
onClick={clearAuthError}
aria-label="Dismiss"
className="cursor-pointer border-none bg-transparent px-0.5 py-0 text-inherit"
>
&times;
</button>
</div>
) : null}
<Button
variant="primary"
size="sm"
type="button"
disabled={isLoading}
onClick={() => {
clearAuthError();
void signIn(
"github",
signInRedirectTo ? { redirectTo: signInRedirectTo } : undefined,
).catch((error) => {
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
});
}}
>
<Github size={16} aria-hidden="true" />
<span className="sign-in-label">Sign in</span>
<span className="sign-in-provider">with GitHub</span>
</Button>
</>
)}
</div>
</div>
) : null}
<SignInButton
variant="primary"
size="sm"
disabled={isLoading}
>
<span>Sign in</span>
<span className="hidden text-white/70 sm:inline">with GitHub</span>
</SignInButton>
</>
)}
</div>
{/* Mobile search bar (expandable) */}
{mobileSearchOpen ? (
<form className="navbar-search-mobile" onSubmit={handleNavSearch}>
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="navbar-search-input"
type="text"
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
value={navSearchQuery}
onChange={(e) => setNavSearchQuery(e.target.value)}
autoFocus
/>
</form>
) : null}
{/* Row 2: Content type tabs */}
<nav className="navbar-tabs" aria-label="Content types">
<div className="navbar-tabs-primary">
{isSoulMode ? (
<a href={clawHubUrl} className="navbar-tab">
ClawHub
</a>
) : null}
{primaryItems.map((item) => {
const Icon = item.icon ? NAV_ICONS[item.icon] : null;
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
location.pathname.startsWith(prefix)
);
return (
<Link
key={item.to + item.label}
to={item.to}
className="navbar-tab"
search={item.search ?? {}}
data-status={isActiveByPrefix ? "active" : undefined}
>
{Icon ? <Icon size={14} className="opacity-50" aria-hidden="true" /> : null}
{item.label}
</Link>
);
})}
</div>
<div className="navbar-tabs-secondary">
{secondaryItems.map((item) => {
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
location.pathname.startsWith(prefix)
);
return (
<Link
key={item.to + item.label}
to={item.to}
search={item.search ?? {}}
className="navbar-tab navbar-tab-secondary"
data-status={isActiveByPrefix ? "active" : undefined}
>
{item.label === "Management" ? "Manage" : item.label}
</Link>
);
})}
</div>
</nav>
</div>
</header>
);
}
function getCurrentRelativeUrl() {
if (typeof window === "undefined") return "/";
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
function getThemeModeIcon(mode: "system" | "light" | "dark") {
switch (mode) {
case "light":
return Sun;
case "dark":
return Moon;
case "system":
default:
return Monitor;
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ export function InstallSwitcher({ exampleSlug = "sonoscli" }: InstallSwitcherPro
type="button"
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
pm === entry.id
? "bg-accent text-accent-fg shadow-sm"
? "bg-[color:var(--accent)] text-white shadow-sm"
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
role="tab"
-63
View File
@@ -1,63 +0,0 @@
import { FileText, Package, Plug, User } from "lucide-react";
type MarketplaceIconProps = {
kind: "skill" | "plugin" | "soul" | "user";
label: string;
imageUrl?: string | null;
size?: "sm" | "md";
};
const TONES = [
{ accent: "oklch(0.63 0.16 42)", wash: "oklch(0.95 0.04 42)" },
{ accent: "oklch(0.61 0.15 168)", wash: "oklch(0.95 0.04 168)" },
{ accent: "oklch(0.59 0.14 236)", wash: "oklch(0.95 0.04 236)" },
{ accent: "oklch(0.66 0.13 92)", wash: "oklch(0.96 0.04 92)" },
] as const;
function hashTone(label: string) {
let sum = 0;
for (const char of label) sum += char.charCodeAt(0);
return TONES[sum % TONES.length] ?? TONES[0];
}
function getIcon(kind: MarketplaceIconProps["kind"]) {
switch (kind) {
case "plugin":
return Plug;
case "soul":
return FileText;
case "user":
return User;
default:
return Package;
}
}
export function MarketplaceIcon({
kind,
label,
imageUrl,
size = "sm",
}: MarketplaceIconProps) {
const Icon = getIcon(kind);
const tone = hashTone(label);
return (
<span
className={`marketplace-icon marketplace-icon-${size}`}
style={
{
"--marketplace-icon-accent": tone.accent,
"--marketplace-icon-wash": tone.wash,
} as React.CSSProperties
}
aria-hidden="true"
>
{imageUrl ? (
<img className="marketplace-icon-image" src={imageUrl} alt="" loading="lazy" />
) : (
<Icon className="marketplace-icon-glyph" strokeWidth={1.8} />
)}
</span>
);
}
-40
View File
@@ -1,40 +0,0 @@
import { Link } from "@tanstack/react-router";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Badge } from "./ui/badge";
import { familyLabel } from "../lib/packageLabels";
import type { PackageListItem } from "../lib/packageApi";
type PluginListItemProps = {
item: PackageListItem;
};
export function PluginListItem({ item }: PluginListItemProps) {
return (
<Link to="/plugins/$name" params={{ name: item.name }} className="skill-list-item" aria-label={`Plugin: ${item.displayName}`}>
<MarketplaceIcon kind="plugin" label={item.displayName} />
<div className="skill-list-item-body">
<div className="skill-list-item-main">
{item.ownerHandle ? (
<>
<span className="skill-list-item-owner">@{item.ownerHandle}</span>
<span className="skill-list-item-sep">/</span>
</>
) : null}
<span className="skill-list-item-name">{item.displayName}</span>
<Badge variant="compact">{familyLabel(item.family)}</Badge>
{item.isOfficial ? <Badge variant="accent">Verified</Badge> : null}
</div>
<p className="skill-list-item-summary">{item.summary ?? "Plugin package for agent workflows."}</p>
<div className="skill-list-item-meta">
<span className="skill-list-item-meta-item">Plugin</span>
{item.latestVersion ? (
<span className="skill-list-item-meta-item">v{item.latestVersion}</span>
) : null}
<span className="skill-list-item-meta-item">
{item.ownerHandle ? `@${item.ownerHandle}` : "community"}
</span>
</div>
</div>
</Link>
);
}
+28 -11
View File
@@ -1,8 +1,8 @@
import { Link } from "@tanstack/react-router";
import { ShieldCheck } from "lucide-react";
import type { ReactNode } from "react";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Badge } from "./ui/badge";
import type { PublicSkill } from "../lib/publicUser";
import { Badge } from "./ui/badge";
type SkillCardProps = {
skill: PublicSkill;
@@ -12,6 +12,7 @@ type SkillCardProps = {
summaryFallback: string;
meta: ReactNode;
href?: string;
verified?: boolean;
};
export function SkillCard({
@@ -22,6 +23,7 @@ export function SkillCard({
summaryFallback,
meta,
href,
verified,
}: SkillCardProps) {
const owner = encodeURIComponent(String(skill.ownerUserId));
const link = href ?? `/${owner}/${skill.slug}`;
@@ -29,28 +31,43 @@ export function SkillCard({
const hasTags = badges.length || chip || platformLabels?.length;
return (
<Link to={link} className="card skill-card">
<Link
to={link}
className="group flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[22px] no-underline transition-all duration-200 ease-out hover:-translate-y-0.5 hover:shadow-[0_12px_28px_rgba(29,26,23,0.12)] hover:border-[color:var(--border-ui-hover)]"
>
{hasTags ? (
<div className="skill-card-tags">
<div className="flex flex-wrap items-center gap-1.5">
{badges.map((label) => (
<Badge key={label}>
<Badge key={label} variant="default">
{label}
</Badge>
))}
{chip ? <Badge variant="accent">{chip}</Badge> : null}
{chip ? (
<Badge variant="accent" className="text-[0.72rem] px-2.5 py-0.5">
{chip}
</Badge>
) : null}
{platformLabels?.map((label) => (
<Badge key={label} variant="compact">
{label}
</Badge>
))}
{verified && (
<span className="inline-flex items-center gap-1 text-[0.72rem] font-semibold text-emerald-600 dark:text-emerald-400">
<ShieldCheck className="h-3.5 w-3.5" />
</span>
)}
</div>
) : null}
<div className="skill-card-header">
<MarketplaceIcon kind="skill" label={skill.displayName} size="md" />
<h3 className="skill-card-title">{skill.displayName}</h3>
<h3 className="font-display text-base font-bold leading-tight text-[color:var(--ink)] group-hover:text-[color:var(--accent)]">
{skill.displayName}
</h3>
<p className="line-clamp-2 text-sm leading-relaxed text-[color:var(--ink-soft)]">
{skill.summary ?? summaryFallback}
</p>
<div className="mt-auto flex flex-col gap-2 pt-1 text-[0.82rem] text-[color:var(--ink-soft)]">
{meta}
</div>
<p className="skill-card-summary">{skill.summary ?? summaryFallback}</p>
<div className="skill-card-footer">{meta}</div>
</Link>
);
}
+106 -111
View File
@@ -2,16 +2,19 @@ import { useNavigate } from "@tanstack/react-router";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { useAction, useMutation, useQuery } from "convex/react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { canManageSkill, isModerator } from "../lib/roles";
import { hasOwnProperty } from "../lib/hasOwnProperty";
import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage";
import { useAuthStatus } from "../lib/useAuthStatus";
import { Card } from "./ui/card";
import { ClientOnly } from "./ClientOnly";
import { EmptyState } from "./EmptyState";
import { Container } from "./layout/Container";
import { SkillDetailSkeleton } from "./skeletons/SkillDetailSkeleton";
import { SkillCommentsPanel } from "./SkillCommentsPanel";
import { SkillDetailTabs, type DetailTab } from "./SkillDetailTabs";
import { SkillMetadataSidebar } from "./SkillMetadataSidebar";
import { SkillDetailTabs } from "./SkillDetailTabs";
import {
buildSkillHref,
formatConfigSnippet,
@@ -22,6 +25,8 @@ import {
import { SkillHeader } from "./SkillHeader";
import { SkillOwnershipPanel } from "./SkillOwnershipPanel";
import { SkillReportDialog } from "./SkillReportDialog";
import { Card } from "./ui/card";
import { Skeleton } from "./ui/skeleton";
type SkillDetailPageProps = {
slug: string;
@@ -33,13 +38,11 @@ type SkillDetailPageProps = {
type SkillFile = Doc<"skillVersions">["files"][number];
function formatReportError(error: unknown) {
if (error && typeof error === "object" && "data" in error) {
if (hasOwnProperty(error, "data")) {
const data = (error as { data?: unknown }).data;
if (typeof data === "string" && data.trim()) return data.trim();
if (
data &&
typeof data === "object" &&
"message" in data &&
hasOwnProperty(data, "message") &&
typeof (data as { message?: unknown }).message === "string"
) {
const message = (data as { message?: string }).message?.trim();
@@ -95,7 +98,7 @@ export function SkillDetailPage({
);
const [tagName, setTagName] = useState("latest");
const [tagVersionId, setTagVersionId] = useState<Id<"skillVersions"> | "">("");
const [activeTab, setActiveTab] = useState<DetailTab>("readme");
const [activeTab, setActiveTab] = useState<"files" | "compare" | "versions">("files");
const [shouldPrefetchCompare, setShouldPrefetchCompare] = useState(false);
const [isReportDialogOpen, setIsReportDialogOpen] = useState(false);
const [reportReason, setReportReason] = useState("");
@@ -212,6 +215,7 @@ export function SkillDetailPage({
?.clawdis;
const osLabels = useMemo(() => formatOsList(clawdis?.os), [clawdis?.os]);
const nixPlugin = clawdis?.nix?.plugin;
const nixSystems = clawdis?.nix?.systems ?? [];
const nixSnippet = nixPlugin ? formatNixInstallSnippet(nixPlugin) : null;
const configRequirements = clawdis?.config;
const configExample = configRequirements?.example
@@ -295,10 +299,13 @@ export function SkillDetailPage({
const deleteTag = (tag: string) => {
if (!skill) return;
if (!window.confirm(`Delete tag "${tag}"?`)) return;
void deleteTags({
skillId: skill._id,
tags: [tag],
toast(`Delete tag "${tag}"?`, {
action: {
label: "Delete",
onClick: () => {
void deleteTags({ skillId: skill._id, tags: [tag] });
},
},
});
};
@@ -317,9 +324,9 @@ export function SkillDetailPage({
const submission = await reportSkill({ skillId: skill._id, reason: trimmedReason });
closeReportDialog();
if (submission.reported) {
window.alert("Thanks — your report has been submitted.");
toast.success("Thanks — your report has been submitted.");
} else {
window.alert("You have already reported this skill.");
toast.info("You have already reported this skill.");
}
} catch (error) {
console.error("Failed to report skill", error);
@@ -329,19 +336,19 @@ export function SkillDetailPage({
};
if (isLoadingSkill || wantsCanonicalRedirect) {
return (
<main className="section">
<Card>
<div className="loading-indicator">Loading skill</div>
</Card>
</main>
);
return <SkillDetailSkeleton />;
}
if (result === null || !skill) {
return (
<main className="section">
<Card>Skill not found.</Card>
<main className="py-10">
<Container>
<EmptyState
title="Skill not found"
description="The skill you're looking for doesn't exist or may have been removed."
action={{ label: "Browse skills", href: "/skills" }}
/>
</Container>
</main>
);
}
@@ -349,91 +356,80 @@ export function SkillDetailPage({
const tagEntries = Object.entries(skill.tags ?? {}) as Array<[string, Id<"skillVersions">]>;
return (
<main className="section">
<div className="skill-detail-stack">
<SkillHeader
skill={skill}
owner={owner}
ownerHandle={ownerHandle}
latestVersion={latestVersion}
modInfo={modInfo}
canManage={canManage}
isAuthenticated={isAuthenticated}
isStaff={isStaff}
isStarred={isStarred}
onToggleStar={() => void toggleStar({ skillId: skill._id })}
onOpenReport={openReportDialog}
forkOf={forkOf}
forkOfLabel={forkOfLabel}
forkOfHref={forkOfHref}
forkOfOwnerHandle={forkOfOwnerHandle}
canonical={canonical}
canonicalHref={canonicalHref}
canonicalOwnerHandle={canonicalOwnerHandle}
staffModerationNote={staffModerationNote}
staffVisibilityTag={staffVisibilityTag}
isAutoHidden={isAutoHidden}
isRemoved={isRemoved}
nixPlugin={nixPlugin}
hasPluginBundle={hasPluginBundle}
configRequirements={configRequirements}
cliHelp={cliHelp}
tagEntries={tagEntries}
versionById={versionById}
tagName={tagName}
onTagNameChange={setTagName}
tagVersionId={tagVersionId}
onTagVersionChange={setTagVersionId}
onTagSubmit={submitTag}
onTagDelete={deleteTag}
tagVersions={versions ?? []}
clawdis={clawdis}
osLabels={osLabels}
/>
{isOwner && skill ? (
<SkillOwnershipPanel
skillId={skill._id}
slug={skill.slug}
<main className="py-10">
<Container>
<div className="flex flex-col gap-6">
<SkillHeader
skill={skill}
owner={owner}
ownerHandle={ownerHandle}
ownerId={owner?._id ?? null}
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
latestVersion={latestVersion}
modInfo={modInfo}
canManage={canManage}
isAuthenticated={isAuthenticated}
isStaff={isStaff}
isStarred={isStarred}
onToggleStar={() => void toggleStar({ skillId: skill._id })}
onOpenReport={openReportDialog}
forkOf={forkOf}
forkOfLabel={forkOfLabel}
forkOfHref={forkOfHref}
forkOfOwnerHandle={forkOfOwnerHandle}
canonical={canonical}
canonicalHref={canonicalHref}
canonicalOwnerHandle={canonicalOwnerHandle}
staffModerationNote={staffModerationNote}
staffVisibilityTag={staffVisibilityTag}
isAutoHidden={isAutoHidden}
isRemoved={isRemoved}
nixPlugin={nixPlugin}
hasPluginBundle={hasPluginBundle}
configRequirements={configRequirements}
cliHelp={cliHelp}
tagEntries={tagEntries}
versionById={versionById}
tagName={tagName}
onTagNameChange={setTagName}
tagVersionId={tagVersionId}
onTagVersionChange={setTagVersionId}
onTagSubmit={submitTag}
onTagDelete={deleteTag}
tagVersions={versions ?? []}
clawdis={clawdis}
osLabels={osLabels}
/>
) : null}
<SkillMetadataSidebar
skill={skill}
latestVersion={latestVersion}
owner={owner}
ownerHandle={ownerHandle}
clawdis={clawdis}
osLabels={osLabels}
tagEntries={tagEntries}
isMalwareBlocked={modInfo?.isMalwareBlocked}
isRemoved={modInfo?.isRemoved}
nixPlugin={nixPlugin}
/>
{isOwner && skill ? (
<SkillOwnershipPanel
skillId={skill._id}
slug={skill.slug}
ownerHandle={ownerHandle}
ownerId={owner?._id ?? null}
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
/>
) : null}
<div className="detail-content-full">
{nixSnippet ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
Install via Nix
</h3>
<pre className="hero-install-code mt-2">
{nixSnippet}
</pre>
</h2>
<p className="text-sm text-[color:var(--ink-soft)]">
{nixSystems.length ? `Systems: ${nixSystems.join(", ")}` : "nix-clawdbot"}
</p>
<pre className="hero-install-code mt-3">{nixSnippet}</pre>
</Card>
) : null}
{configExample ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
Config example
</h3>
<pre className="hero-install-code mt-2">
{configExample}
</pre>
</h2>
<p className="text-sm text-[color:var(--ink-soft)]">
Starter config for this plugin bundle.
</p>
<pre className="hero-install-code mt-3">{configExample}</pre>
</Card>
) : null}
@@ -456,12 +452,11 @@ export function SkillDetailPage({
<ClientOnly
fallback={
<Card>
<h2 className="section-title text-[1.2rem] m-0">
Comments
</h2>
<p className="section-subtitle mt-3 mb-0">
Loading comments...
</p>
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Comments</h2>
<div className="flex flex-col gap-3 pt-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-20 w-full" />
</div>
</Card>
}
>
@@ -472,17 +467,17 @@ export function SkillDetailPage({
/>
</ClientOnly>
</div>
</div>
<SkillReportDialog
isOpen={isAuthenticated && isReportDialogOpen}
isSubmitting={isSubmittingReport}
reportReason={reportReason}
reportError={reportError}
onReasonChange={setReportReason}
onCancel={closeReportDialog}
onSubmit={() => void submitReport()}
/>
<SkillReportDialog
isOpen={isAuthenticated && isReportDialogOpen}
isSubmitting={isSubmittingReport}
reportReason={reportReason}
reportError={reportError}
onReasonChange={setReportReason}
onCancel={closeReportDialog}
onSubmit={() => void submitReport()}
/>
</Container>
</main>
);
}
+38 -83
View File
@@ -1,8 +1,9 @@
import { lazy, Suspense } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { SkillVersionsPanel } from "./SkillVersionsPanel";
import { Card } from "./ui/card";
import { Skeleton } from "./ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
const SkillDiffCard = lazy(() =>
import("./SkillDiffCard").then((module) => ({ default: module.SkillDiffCard })),
@@ -14,11 +15,9 @@ const SkillFilesPanel = lazy(() =>
type SkillFile = Doc<"skillVersions">["files"][number];
export type DetailTab = "readme" | "files" | "compare" | "versions";
type SkillDetailTabsProps = {
activeTab: DetailTab;
setActiveTab: (tab: DetailTab) => void;
activeTab: "files" | "compare" | "versions";
setActiveTab: (tab: "files" | "compare" | "versions") => void;
onCompareIntent: () => void;
readmeContent: string | null;
readmeError: string | null;
@@ -47,30 +46,13 @@ export function SkillDetailTabs({
suppressVersionScanResults,
scanResultsSuppressedMessage,
}: SkillDetailTabsProps) {
const compareEnabled = (versions?.length ?? 0) > 1;
return (
<div className="card tab-card">
<div className="tab-header">
<button
className={`tab-button${activeTab === "readme" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("readme")}
>
README
</button>
<button
className={`tab-button${activeTab === "files" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("files")}
>
Files
</button>
{compareEnabled ? (
<button
className={`tab-button${activeTab === "compare" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("compare")}
<Card>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as typeof activeTab)}>
<TabsList>
<TabsTrigger value="files">Files</TabsTrigger>
<TabsTrigger
value="compare"
onMouseEnter={() => {
onCompareIntent();
void import("./SkillDiffCard");
@@ -81,64 +63,37 @@ export function SkillDetailTabs({
}}
>
Compare
</button>
) : null}
<button
className={`tab-button${activeTab === "versions" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("versions")}
>
Versions
</button>
</div>
</TabsTrigger>
<TabsTrigger value="versions">Versions</TabsTrigger>
</TabsList>
{activeTab === "readme" ? (
<div className="tab-body">
{readmeContent ? (
<div className="markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readmeContent}</ReactMarkdown>
</div>
) : readmeError ? (
<div className="empty-state px-[var(--space-4)] py-[var(--space-6)]">
<p className="empty-state-title">No README available</p>
<p className="empty-state-body">
This skill doesn't have a SKILL.md file yet.
</p>
</div>
) : (
<div className="stat p-4">
Loading README...
</div>
)}
</div>
) : null}
<TabsContent value="files">
<Suspense fallback={<Skeleton className="h-40 w-full" />}>
<SkillFilesPanel
versionId={latestVersionId}
readmeContent={readmeContent}
readmeError={readmeError}
latestFiles={latestFiles}
/>
</Suspense>
</TabsContent>
{activeTab === "files" ? (
<Suspense fallback={<div className="tab-body stat">Loading file viewer...</div>}>
<SkillFilesPanel
versionId={latestVersionId}
latestFiles={latestFiles}
/>
</Suspense>
) : null}
{activeTab === "compare" ? (
<div className="tab-body">
<Suspense fallback={<div className="stat">Loading diff viewer...</div>}>
<TabsContent value="compare">
<Suspense fallback={<Skeleton className="h-40 w-full" />}>
<SkillDiffCard skill={skill} versions={diffVersions ?? []} variant="embedded" />
</Suspense>
</div>
) : null}
</TabsContent>
{activeTab === "versions" ? (
<SkillVersionsPanel
versions={versions}
nixPlugin={nixPlugin}
skillSlug={skill.slug}
suppressScanResults={suppressVersionScanResults}
suppressedMessage={scanResultsSuppressedMessage}
/>
) : null}
</div>
<TabsContent value="versions">
<SkillVersionsPanel
versions={versions}
nixPlugin={nixPlugin}
skillSlug={skill.slug}
suppressScanResults={suppressVersionScanResults}
suppressedMessage={scanResultsSuppressedMessage}
/>
</TabsContent>
</Tabs>
</Card>
);
}
+2 -2
View File
@@ -83,7 +83,7 @@ describe("SkillDiffCard", () => {
await waitFor(() => {
expect(screen.getByTestId("diff-editor").getAttribute("data-side-by-side")).toBe("false");
});
expect(screen.getByRole("button", { name: "Inline" }).className).toContain("is-active");
expect(screen.getByRole("button", { name: "Inline" }).className).toContain("shadow-sm");
expect(screen.getByTestId("diff-editor").getAttribute("data-inline-fallback")).toBe("false");
});
@@ -106,6 +106,6 @@ describe("SkillDiffCard", () => {
await waitFor(() => {
expect(screen.getByTestId("diff-editor").getAttribute("data-side-by-side")).toBe("true");
});
expect(screen.getByRole("button", { name: "Side-by-side" }).className).toContain("is-active");
expect(screen.getByRole("button", { name: "Side-by-side" }).className).toContain("shadow-sm");
});
});
+138 -109
View File
@@ -13,9 +13,12 @@ import {
selectDefaultFilePath,
sortVersionsBySemver,
} from "../lib/diffing";
import { isDarkThemeResolved, onThemeChange } from "../lib/theme";
import { Button } from "./ui/button";
import { ClientOnly } from "./ClientOnly";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card } from "./ui/card";
import { Label } from "./ui/label";
import { Skeleton } from "./ui/skeleton";
type SkillDiffCardProps = {
skill: Doc<"skills">;
@@ -233,18 +236,15 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
useEffect(() => {
if (!monaco || typeof document === "undefined") return;
const syncTheme = () => applyMonacoTheme(monaco);
const observer = new MutationObserver(syncTheme);
const observer = new MutationObserver(() => {
applyMonacoTheme(monaco);
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "data-theme-family", "data-theme-resolved"],
attributeFilter: ["data-theme"],
});
const removeThemeListener = onThemeChange(syncTheme);
syncTheme();
return () => {
observer.disconnect();
removeThemeListener();
};
applyMonacoTheme(monaco);
return () => observer.disconnect();
}, [monaco]);
useEffect(() => {
@@ -277,145 +277,174 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
const fileSelected = Boolean(selectedItem);
const diffOptions = useMemo(() => buildDiffOptions(viewMode), [viewMode]);
const containerClass = variant === "card" ? "card diff-card" : "diff-card diff-card-embedded";
const Wrapper = variant === "card" ? Card : "div";
const wrapperClassName = variant === "card" ? "flex flex-col gap-4" : "flex flex-col gap-4";
return (
<div className={containerClass}>
<div className="diff-header">
<Wrapper className={wrapperClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="section-title text-[1.2rem] m-0">
<h2 className="m-0 font-display text-[1.2rem] font-bold text-[color:var(--ink)]">
Compare versions
</h2>
<p className="section-subtitle m-0">
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
Inline or side-by-side diff for any file.
</p>
</div>
{!diffUnavailable ? (
<fieldset className="diff-toggle-group">
<legend className="sr-only">Diff layout</legend>
<button
className={`diff-toggle${viewMode === "split" ? " is-active" : ""}`}
type="button"
onClick={() => updateViewMode("split")}
>
Side-by-side
</button>
<button
className={`diff-toggle${viewMode === "inline" ? " is-active" : ""}`}
type="button"
onClick={() => updateViewMode("inline")}
>
Inline
</button>
</fieldset>
) : null}
<fieldset className="inline-flex items-center gap-0.5 rounded-full border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-[3px]">
<legend className="sr-only">Diff layout</legend>
<button
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
viewMode === "split"
? "bg-[color:var(--surface)] text-[color:var(--ink)] shadow-sm"
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
type="button"
onClick={() => updateViewMode("split")}
>
Side-by-side
</button>
<button
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
viewMode === "inline"
? "bg-[color:var(--surface)] text-[color:var(--ink)] shadow-sm"
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
type="button"
onClick={() => updateViewMode("inline")}
>
Inline
</button>
</fieldset>
</div>
{!diffUnavailable ? (
<>
<div className="diff-controls">
<div className="diff-select">
<label htmlFor="diff-left">Left</label>
<select
id="diff-left"
className="search-input"
value={leftVersionId ?? ""}
onChange={(event) => setLeftVersionId(event.target.value as Id<"skillVersions">)}
>
<option value="" disabled>
Select version
</option>
{renderOptions(versionOptions)}
</select>
</div>
<Button
className="diff-swap"
type="button"
onClick={() => {
setLeftVersionId(rightVersionId);
setRightVersionId(leftVersionId);
}}
disabled={!leftVersionId || !rightVersionId}
>
Swap
</Button>
<div className="diff-select">
<label htmlFor="diff-right">Right</label>
<select
id="diff-right"
className="search-input"
value={rightVersionId ?? ""}
onChange={(event) => setRightVersionId(event.target.value as Id<"skillVersions">)}
>
<option value="" disabled>
Select version
</option>
{renderOptions(versionOptions)}
</select>
</div>
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="flex min-w-[140px] flex-1 flex-col gap-1">
<Label htmlFor="diff-left">Left</Label>
<select
id="diff-left"
className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] focus:outline-none dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
value={leftVersionId ?? ""}
onChange={(event) => setLeftVersionId(event.target.value as Id<"skillVersions">)}
>
<option value="" disabled>
Select version
</option>
{renderOptions(versionOptions)}
</select>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setLeftVersionId(rightVersionId);
setRightVersionId(leftVersionId);
}}
disabled={!leftVersionId || !rightVersionId}
>
Swap
</Button>
<div className="flex min-w-[140px] flex-1 flex-col gap-1">
<Label htmlFor="diff-right">Right</Label>
<select
id="diff-right"
className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] focus:outline-none dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
value={rightVersionId ?? ""}
onChange={(event) => setRightVersionId(event.target.value as Id<"skillVersions">)}
>
<option value="" disabled>
Select version
</option>
{renderOptions(versionOptions)}
</select>
</div>
</div>
<div className="diff-meta">
<span>
Left {leftLabel} Right {rightLabel}
</span>
</div>
</>
) : null}
<div className="flex flex-wrap items-center gap-3 text-sm text-[color:var(--ink-soft)]">
<span>
Left {leftLabel} Right {rightLabel}
</span>
{diffUnavailable ? <span>Need at least 2 versions.</span> : null}
</div>
<div className="diff-layout">
<div className="diff-files">
{diffUnavailable ? (
<div className="diff-empty">
Publish another version to compare changes side by side.
</div>
) : fileDiffItems.length === 0 ? (
<div className="diff-empty">No files to compare.</div>
<div className="grid gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] md:grid-cols-[minmax(160px,220px)_1fr]">
<div className="flex max-h-[500px] flex-col overflow-y-auto border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] md:border-r md:border-b-0">
{fileDiffItems.length === 0 ? (
<div className="p-3 text-sm text-[color:var(--ink-soft)]">No files to compare.</div>
) : (
fileDiffItems.map((item) => (
<button
key={item.path}
type="button"
className={`diff-file${item.path === selectedPath ? " is-active" : ""}`}
className={`flex w-full cursor-pointer items-center gap-2 border-none px-3 py-2 text-left text-sm transition-colors hover:bg-[color:var(--surface)] ${
item.path === selectedPath
? "bg-[color:var(--surface)] font-semibold text-[color:var(--ink)]"
: "bg-transparent text-[color:var(--ink)]"
}`}
onClick={() => setSelectedPath(item.path)}
>
<span className={`diff-pill diff-pill-${item.status}`}>{item.status}</span>
<span className="diff-file-name">{item.path}</span>
<Badge
variant={
item.status === "added"
? "success"
: item.status === "removed"
? "destructive"
: item.status === "changed"
? "warning"
: "compact"
}
className="shrink-0 text-[0.65rem]"
>
{item.status}
</Badge>
<span className="truncate font-mono text-xs">{item.path}</span>
</button>
))
)}
</div>
<div className="diff-view">
<div className="relative min-h-[300px]">
{error ? (
<div className="diff-empty">{error}</div>
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
{error}
</div>
) : sizeWarning ? (
<div className="diff-empty">
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
{sizeWarning.side === "left" ? "Left" : "Right"} file exceeds 200KB:{" "}
{sizeWarning.path}
</div>
) : diffUnavailable ? (
<div className="diff-empty">Publish another version to compare.</div>
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
Publish another version to compare.
</div>
) : !selectionReady ? (
<div className="diff-empty">Select two versions to compare.</div>
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
Select two versions to compare.
</div>
) : !fileSelected ? (
<div className="diff-empty">Select a file to compare.</div>
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
Select a file to compare.
</div>
) : (
<ClientOnly fallback={<div className="diff-empty">Preparing diff</div>}>
<ClientOnly fallback={<Skeleton className="h-full w-full" />}>
<DiffEditor
key={`diff-${viewMode}`}
className={`diff-monaco diff-monaco-${viewMode}`}
className={`h-full min-h-[400px] w-full ${viewMode === "inline" ? "max-w-full" : ""}`}
original={leftText}
modified={rightText}
theme={getMonacoThemeName()}
loading={<div className="diff-empty">Loading diff</div>}
loading={<Skeleton className="h-full w-full" />}
options={diffOptions}
/>
{isLoading ? <div className="diff-loading">Loading</div> : null}
{isLoading ? (
<div className="absolute inset-0 flex items-center justify-center bg-[color:var(--surface)]/80">
<Skeleton className="h-8 w-24" />
</div>
) : null}
</ClientOnly>
)}
</div>
</div>
</div>
</Wrapper>
);
}
@@ -443,7 +472,7 @@ function renderOptions(options: VersionOption[]) {
function getMonacoThemeName() {
if (typeof document === "undefined") return "clawhub-light";
return isDarkThemeResolved() ? "clawhub-dark" : "clawhub-light";
return document.documentElement.dataset.theme === "dark" ? "clawhub-dark" : "clawhub-light";
}
function buildDiffOptions(viewMode: "split" | "inline"): DiffEditorProps["options"] {
@@ -479,7 +508,7 @@ function applyMonacoTheme(monaco: NonNullable<ReturnType<typeof useMonaco>>) {
const diffDiagonal = styles.getPropertyValue("--diff-diagonal").trim() || "#22222233";
const background = surface;
const gutter = surfaceMuted;
const isDark = isDarkThemeResolved();
const isDark = document.documentElement.dataset.theme === "dark";
const base = isDark ? "vs-dark" : "vs";
const diffInserted = withAlpha(diffAdded, isDark ? 0.22 : 0.2);
+8
View File
@@ -9,6 +9,10 @@ vi.mock("convex/react", () => ({
useAction: () => getFileTextMock,
}));
vi.mock("./MarkdownPreview", () => ({
MarkdownPreview: ({ children }: { children: string }) => <div>{children}</div>,
}));
type SkillFile = Doc<"skillVersions">["files"][number];
function makeFile(path: string, size: number): SkillFile {
@@ -30,6 +34,8 @@ describe("SkillFilesPanel", () => {
render(
<SkillFilesPanel
versionId={"skillVersions:1" as Id<"skillVersions">}
readmeContent={"# skill"}
readmeError={null}
latestFiles={[makeFile("scripts/run.sh", 10)]}
/>,
);
@@ -62,6 +68,8 @@ describe("SkillFilesPanel", () => {
render(
<SkillFilesPanel
versionId={"skillVersions:1" as Id<"skillVersions">}
readmeContent={"# skill"}
readmeError={null}
latestFiles={[makeFile("a.txt", 5), makeFile("b.txt", 6)]}
/>,
);
+53 -21
View File
@@ -2,17 +2,23 @@ import { useAction } from "convex/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { MarkdownPreview } from "./MarkdownPreview";
import { formatBytes } from "./skillDetailUtils";
import { Skeleton } from "./ui/skeleton";
type SkillFile = Doc<"skillVersions">["files"][number];
type SkillFilesPanelProps = {
versionId: Id<"skillVersions"> | null;
readmeContent: string | null;
readmeError: string | null;
latestFiles: SkillFile[];
};
export function SkillFilesPanel({
versionId,
readmeContent,
readmeError,
latestFiles,
}: SkillFilesPanelProps) {
const getFileText = useAction(api.skills.getFileText);
@@ -85,56 +91,82 @@ export function SkillFilesPanel({
);
return (
<div className="tab-body">
<div className="file-browser">
<div className="file-list">
<div className="file-list-header">
<h3 className="section-title text-[1.05rem] m-0">
<div className="grid max-w-full gap-5 overflow-x-auto">
<div>
<h2 className="m-0 font-display text-[1.2rem] font-bold text-[color:var(--ink)]">
SKILL.md
</h2>
<div>
{readmeContent ? (
<MarkdownPreview>{readmeContent}</MarkdownPreview>
) : readmeError ? (
<div className="text-sm text-[color:var(--ink-soft)]">
Failed to load SKILL.md: {readmeError}
</div>
) : (
<Skeleton className="h-24 w-full" />
)}
</div>
</div>
<div className="grid gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] md:grid-cols-[minmax(180px,280px)_1fr]">
<div className="flex flex-col border-b border-[color:var(--line)] md:border-r md:border-b-0">
<div className="flex items-center justify-between border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-2">
<h3 className="m-0 font-display text-[1.05rem] font-bold text-[color:var(--ink)]">
Files
</h3>
<span className="section-subtitle m-0">
<span className="m-0 text-sm text-[color:var(--ink-soft)]">
{latestFiles.length} total
</span>
</div>
<div className="file-list-body">
<div className="flex max-h-[400px] flex-col overflow-y-auto">
{latestFiles.length === 0 ? (
<div className="stat">No files available.</div>
<div className="px-3 py-2 text-sm text-[color:var(--ink-soft)]">
No files available.
</div>
) : (
latestFiles.map((file) => (
<button
key={file.path}
className={`file-row file-row-button${
selectedPath === file.path ? " is-active" : ""
className={`flex w-full cursor-pointer items-center justify-between border-none px-3 py-2 text-left text-sm transition-colors hover:bg-[color:var(--surface-muted)] ${
selectedPath === file.path
? "bg-[color:var(--surface-muted)] font-semibold text-[color:var(--ink)]"
: "bg-transparent text-[color:var(--ink)]"
}`}
type="button"
onClick={() => handleSelect(file.path)}
aria-current={selectedPath === file.path ? "true" : undefined}
>
<span className="file-path">{file.path}</span>
<span className="file-meta">{formatBytes(file.size)}</span>
<span className="truncate font-mono text-xs">{file.path}</span>
<span className="ml-2 shrink-0 text-xs text-[color:var(--ink-soft)]">
{formatBytes(file.size)}
</span>
</button>
))
)}
</div>
</div>
<div className="file-viewer">
<div className="file-viewer-header">
<div className="file-path">{selectedPath ?? "Select a file"}</div>
<div className="flex flex-col">
<div className="flex items-center justify-between border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-2">
<div className="truncate font-mono text-xs">{selectedPath ?? "Select a file"}</div>
{fileMeta ? (
<span className="file-meta">
<span className="ml-2 shrink-0 text-xs text-[color:var(--ink-soft)]">
{formatBytes(fileMeta.size)} · {fileMeta.sha256.slice(0, 12)}
</span>
) : null}
</div>
<div className="file-viewer-body">
<div className="min-h-[200px] p-3">
{isLoading ? (
<div className="stat">Loading</div>
<Skeleton className="h-24 w-full" />
) : fileError ? (
<div className="stat">Failed to load file: {fileError}</div>
<div className="text-sm text-[color:var(--ink-soft)]">
Failed to load file: {fileError}
</div>
) : fileContent ? (
<pre className="file-viewer-code">{fileContent}</pre>
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed">
{fileContent}
</pre>
) : (
<div className="stat">Select a file to preview.</div>
<div className="text-sm text-[color:var(--ink-soft)]">Select a file to preview.</div>
)}
</div>
</div>
+209 -156
View File
@@ -1,14 +1,21 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { Link } from "@tanstack/react-router";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_SUMMARY,
} from "clawhub-schema/licenseConstants";
import { Package } from "lucide-react";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { getSkillBadges } from "../lib/badges";
import { formatCompactStat, formatSkillStatsTriplet } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { getRuntimeEnv } from "../lib/runtimeEnv";
import { SkillInstallCard } from "./SkillInstallCard";
import { type LlmAnalysis, SecurityScanResults } from "./SkillSecurityScanResults";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card, CardContent } from "./ui/card";
import { Input } from "./ui/input";
import { UserBadge } from "./UserBadge";
export type SkillModerationInfo = {
@@ -110,9 +117,10 @@ export function SkillHeader({
onTagSubmit,
onTagDelete,
tagVersions,
clawdis: _clawdis,
osLabels: _osLabels,
clawdis,
osLabels,
}: SkillHeaderProps) {
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
const formattedStats = formatSkillStatsTriplet(skill.stats);
const suppressScanResults =
!isStaff &&
@@ -126,8 +134,8 @@ export function SkillHeader({
return (
<>
{modInfo?.isPendingScan ? (
<div className="pending-banner">
<div className="pending-banner-content">
<div className="rounded-[var(--radius-md)] border border-amber-300/50 bg-amber-50 p-5 dark:border-amber-500/30 dark:bg-amber-950/40">
<div className="flex flex-col gap-2">
<strong>Security scan in progress</strong>
<p>
Your skill is being scanned by VirusTotal. It will be visible to others once the scan
@@ -137,8 +145,8 @@ export function SkillHeader({
</div>
</div>
) : modInfo?.isMalwareBlocked ? (
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<div className="rounded-[var(--radius-md)] border border-red-300/50 bg-red-50 p-5 dark:border-red-500/30 dark:bg-red-950/40">
<div className="flex flex-col gap-2">
<strong>Skill blocked malicious content detected</strong>
<p>
ClawHub Security flagged this skill as malicious. Downloads are disabled. Review the
@@ -147,15 +155,15 @@ export function SkillHeader({
</div>
</div>
) : modInfo?.isSuspicious ? (
<div className="pending-banner pending-banner-warning">
<div className="pending-banner-content">
<div className="rounded-[var(--radius-md)] border border-amber-300/50 bg-amber-50 p-5 dark:border-amber-500/30 dark:bg-amber-950/40">
<div className="flex flex-col gap-2">
<strong>Skill flagged suspicious patterns detected</strong>
<p>
ClawHub Security flagged this skill as suspicious. Review the scan results before
using.
</p>
{canManage ? (
<p className="pending-banner-appeal">
<p className="text-sm text-[color:var(--ink-soft)]">
If you believe this skill has been incorrectly flagged, please{" "}
<a
href="https://github.com/openclaw/clawhub/issues"
@@ -170,109 +178,142 @@ export function SkillHeader({
</div>
</div>
) : modInfo?.isRemoved ? (
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<div className="rounded-[var(--radius-md)] border border-red-300/50 bg-red-50 p-5 dark:border-red-500/30 dark:bg-red-950/40">
<div className="flex flex-col gap-2">
<strong>Skill removed by moderator</strong>
<p>This skill has been removed and is not visible to others.</p>
</div>
</div>
) : modInfo?.isHiddenByMod ? (
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<div className="rounded-[var(--radius-md)] border border-red-300/50 bg-red-50 p-5 dark:border-red-500/30 dark:bg-red-950/40">
<div className="flex flex-col gap-2">
<strong>Skill hidden</strong>
<p>This skill is currently hidden and not visible to others.</p>
</div>
</div>
) : null}
<div className="card skill-hero">
<div className={`skill-hero-top${hasPluginBundle ? " has-plugin" : ""}`}>
<div className="skill-hero-header">
<div className="skill-hero-title">
<div className="skill-hero-title-row">
<h1 className="section-title m-0">
{skill.displayName}
</h1>
{latestVersion?.version ? (
<span className="plugin-version-badge">v{latestVersion.version}</span>
) : null}
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
</div>
<p className="section-subtitle">{skill.summary ?? "No summary provided."}</p>
{isStaff && staffModerationNote ? (
<div className="skill-hero-note">{staffModerationNote}</div>
) : null}
{nixPlugin ? (
<div className="skill-hero-note">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
</div>
) : null}
<div className="skill-hero-inline-meta">
<div className="skill-hero-stats-row">
<span className="stat"> {formattedStats.stars}</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat"><Package size={14} aria-hidden="true" /> {formattedStats.downloads}</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">{formatCompactStat(skill.stats.installsCurrent ?? 0)} current</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">{formattedStats.installsAllTime} all-time</span>
</div>
<div className="skill-hero-meta-row">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix="by"
size="md"
showName
/>
{forkOf && forkOfHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
{forkOfLabel}{" "}
<a href={forkOfHref}>
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
{forkOf.skill.slug}
</a>
{forkOf.version ? ` (${forkOf.version})` : null}
</span>
</>
<Card>
<div className={`flex flex-col gap-5${hasPluginBundle ? " pb-2" : ""}`}>
<div className="flex flex-col gap-5 md:flex-row md:gap-8">
<div className="flex flex-1 flex-col gap-3">
<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
{skill.displayName}
</h1>
{latestVersion?.version ? (
<Badge variant="compact">v{latestVersion.version}</Badge>
) : null}
{canonicalHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
canonical:{" "}
<a href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
</>
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
</div>
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
{skill.summary ?? "No summary provided."}
</p>
{isStaff && staffModerationNote ? (
<div className="rounded-[var(--radius-sm)] border border-amber-200/60 bg-amber-50/60 px-3 py-2 text-sm text-[color:var(--ink-soft)] dark:border-amber-500/20 dark:bg-amber-950/30">
{staffModerationNote}
</div>
) : null}
{nixPlugin ? (
<div className="rounded-[var(--radius-sm)] border border-amber-200/60 bg-amber-50/60 px-3 py-2 text-sm text-[color:var(--ink-soft)] dark:border-amber-500/20 dark:bg-amber-950/30">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
</div>
) : null}
<div className="flex flex-col gap-2 pt-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-[color:var(--ink-soft)]">
{formattedStats.stars}
</span>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="flex items-center gap-1 text-sm text-[color:var(--ink-soft)]">
<Package size={14} aria-hidden="true" /> {formattedStats.downloads}
</span>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="text-sm text-[color:var(--ink-soft)]">
{formatCompactStat(skill.stats.installsCurrent ?? 0)} current
</span>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="text-sm text-[color:var(--ink-soft)]">
{formattedStats.installsAllTime} all-time
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix="by"
size="md"
showName
/>
{forkOf && forkOfHref ? (
<>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="text-sm text-[color:var(--ink-soft)]">
{forkOfLabel}{" "}
<a href={forkOfHref}>
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
{forkOf.skill.slug}
</a>
{forkOf.version ? ` (${forkOf.version})` : null}
</span>
</>
) : null}
{canonicalHref ? (
<>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="text-sm text-[color:var(--ink-soft)]">
canonical:{" "}
<a href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
</>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-1.5 pt-1">
<Badge variant="compact">{PLATFORM_SKILL_LICENSE}</Badge>
{getSkillBadges(skill).map((badge) => (
<Badge key={badge} variant="compact">
{badge}
</Badge>
))}
{isStaff && staffVisibilityTag ? (
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
{staffVisibilityTag}
</Badge>
) : null}
</div>
</div>
<div className="skill-hero-badges">
{getSkillBadges(skill).map((badge) => (
<Badge key={badge} variant="compact">
{badge}
</Badge>
))}
{isStaff && staffVisibilityTag ? (
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
{staffVisibilityTag}
</Badge>
) : null}
</div>
</div>
<div className="skill-hero-sidebar">
<div className="skill-actions">
<div className="flex w-full flex-col gap-3 md:w-[220px] md:shrink-0">
{!nixPlugin && !modInfo?.isMalwareBlocked && !modInfo?.isRemoved ? (
<a
href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}
className="inline-flex w-full items-center justify-center gap-2 whitespace-nowrap font-semibold text-sm min-h-[44px] rounded-[var(--radius-pill)] px-4 py-[11px] border-none bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] text-white transition-all duration-200 no-underline hover:-translate-y-px hover:shadow-[0_10px_20px_rgba(29,26,23,0.12)]"
>
Download zip
</a>
) : null}
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold text-[color:var(--ink-soft)]">
License
</span>
<span className="text-sm text-[color:var(--ink)]">
{PLATFORM_SKILL_LICENSE} · {PLATFORM_SKILL_LICENSE_SUMMARY}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{isAuthenticated ? (
<button
className={`star-toggle${isStarred ? " is-active" : ""}`}
className={`flex h-9 w-9 items-center justify-center rounded-full border transition-all duration-200 ${isStarred ? "border-amber-400/60 bg-amber-50 text-amber-500 dark:border-amber-500/40 dark:bg-amber-950/40" : "border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink-soft)] hover:text-amber-500"}`}
type="button"
onClick={onToggleStar}
aria-label={isStarred ? "Unstar skill" : "Star skill"}
@@ -281,16 +322,18 @@ export function SkillHeader({
</button>
) : null}
{isAuthenticated ? (
<Button variant="ghost" size="sm" type="button" onClick={onOpenReport}>
<Button variant="ghost" size="sm" onClick={onOpenReport}>
Report
</Button>
) : null}
{isStaff ? (
<Button asChild size="sm">
<Link to="/management" search={{ skill: skill.slug }}>
Manage
</Link>
</Button>
<Link
to="/management"
search={{ skill: skill.slug }}
className="inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold text-xs min-h-[34px] rounded-[var(--radius-pill)] px-3 py-1.5 border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] transition-all duration-200 no-underline"
>
Manage
</Link>
) : null}
</div>
</div>
@@ -298,12 +341,13 @@ export function SkillHeader({
{/* Security scan — full width below the header columns */}
{suppressScanResults ? (
<div className="skill-hero-note">{overrideScanMessage}</div>
<div className="rounded-[var(--radius-sm)] border border-amber-200/60 bg-amber-50/60 px-3 py-2 text-sm text-[color:var(--ink-soft)] dark:border-amber-500/20 dark:bg-amber-950/30">
{overrideScanMessage}
</div>
) : latestVersion?.sha256hash ||
latestVersion?.llmAnalysis ||
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ||
(latestVersion?.capabilityTags?.length ?? 0) > 0 ? (
<div className="skill-hero-scan-row">
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ? (
<div className="flex flex-col gap-2">
<SecurityScanResults
sha256hash={latestVersion?.sha256hash}
vtAnalysis={latestVersion?.vtAnalysis}
@@ -311,67 +355,77 @@ export function SkillHeader({
staticFindings={latestVersion?.staticScan?.findings}
capabilityTags={latestVersion?.capabilityTags}
/>
<p className="scan-disclaimer">
<p className="text-xs text-[color:var(--ink-soft)]">
Like a lobster shell, security has layers review code before you run it.
</p>
</div>
) : null}
{hasPluginBundle ? (
<div className="skill-panel bundle-card">
<div className="bundle-header">
<div className="bundle-title">Plugin bundle (nix)</div>
<div className="bundle-subtitle">Skill pack · CLI binary · Config</div>
</div>
<div className="bundle-includes">
<span>SKILL.md</span>
<span>CLI</span>
<span>Config</span>
</div>
{configRequirements ? (
<div className="bundle-section">
<div className="bundle-section-title">Config requirements</div>
<div className="bundle-meta">
{configRequirements.requiredEnv?.length ? (
<div className="stat">
<strong>Required env</strong>
<span>{configRequirements.requiredEnv.join(", ")}</span>
</div>
) : null}
{configRequirements.stateDirs?.length ? (
<div className="stat">
<strong>State dirs</strong>
<span>{configRequirements.stateDirs.join(", ")}</span>
</div>
) : null}
<Card className="border-dashed">
<CardContent>
<div className="flex flex-col gap-1">
<div className="font-display text-base font-bold text-[color:var(--ink)]">
Plugin bundle (nix)
</div>
<div className="text-sm text-[color:var(--ink-soft)]">
Skill pack · CLI binary · Config
</div>
</div>
) : null}
{cliHelp ? (
<details className="bundle-section bundle-details">
<summary>CLI help (from plugin)</summary>
<pre className="hero-install-code mono">{cliHelp}</pre>
</details>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Badge>SKILL.md</Badge>
<Badge>CLI</Badge>
<Badge>Config</Badge>
</div>
{configRequirements ? (
<div className="flex flex-col gap-2">
<div className="text-sm font-semibold text-[color:var(--ink)]">
Config requirements
</div>
<div className="flex flex-col gap-1">
{configRequirements.requiredEnv?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Required env</strong>
<span>{configRequirements.requiredEnv.join(", ")}</span>
</div>
) : null}
{configRequirements.stateDirs?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>State dirs</strong>
<span>{configRequirements.stateDirs.join(", ")}</span>
</div>
) : null}
</div>
</div>
) : null}
{cliHelp ? (
<details className="flex flex-col gap-2">
<summary className="cursor-pointer text-sm font-semibold text-[color:var(--ink)]">
CLI help (from plugin)
</summary>
<pre className="mt-2 overflow-x-auto rounded-[var(--radius-sm)] bg-[color:var(--surface-muted)] p-3 font-mono text-xs">
{cliHelp}
</pre>
</details>
) : null}
</CardContent>
</Card>
) : null}
</div>
<div className="skill-tag-row">
<div className="flex flex-wrap items-center gap-2 border-t border-[color:var(--line)] pt-4">
{tagEntries.length === 0 ? (
<span className="section-subtitle m-0">
No tags yet.
</span>
<span className="m-0 text-sm text-[color:var(--ink-soft)]">No tags yet.</span>
) : (
tagEntries.map(([tag, versionId]) => (
<Badge key={tag}>
<Badge key={tag} className="gap-1.5">
{tag}
<span className="tag-meta">
<span className="text-[0.68rem] opacity-70">
v{versionById.get(versionId)?.version ?? versionId}
</span>
{canManage && tag !== "latest" ? (
<button
type="button"
className="tag-delete"
className="ml-0.5 cursor-pointer border-none bg-transparent p-0 text-current opacity-60 hover:opacity-100"
onClick={() => onTagDelete(tag)}
aria-label={`Delete tag ${tag}`}
title={`Delete tag "${tag}"`}
@@ -390,16 +444,16 @@ export function SkillHeader({
event.preventDefault();
onTagSubmit();
}}
className="tag-form"
className="flex flex-wrap items-end gap-2 border-t border-[color:var(--line)] pt-4"
>
<input
className="search-input"
<Input
value={tagName}
onChange={(event) => onTagNameChange(event.target.value)}
placeholder="latest"
className="w-auto max-w-[160px]"
/>
<select
className="search-input"
className="min-h-[44px] rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] focus:outline-none dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
value={tagVersionId ?? ""}
onChange={(event) => onTagVersionChange(event.target.value as Id<"skillVersions">)}
>
@@ -409,13 +463,12 @@ export function SkillHeader({
</option>
))}
</select>
<Button type="submit">
Update tag
</Button>
<Button type="submit">Update tag</Button>
</form>
) : null}
</div>
<SkillInstallCard clawdis={clawdis} osLabels={osLabels} />
</Card>
</>
);
}
+217 -162
View File
@@ -1,6 +1,12 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { Badge } from "./ui/badge";
import {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_SUMMARY,
PLATFORM_SKILL_LICENSE_URL,
} from "clawhub-schema/licenseConstants";
import { formatInstallCommand, formatInstallLabel } from "./skillDetailUtils";
import { Badge } from "./ui/badge";
import { Card, CardContent } from "./ui/card";
type SkillInstallCardProps = {
clawdis: ClawdisSkillMetadata | undefined;
@@ -26,180 +32,229 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
const hasInstallSpecs = installSpecs.length > 0;
const hasDependencies = dependencies.length > 0;
const hasLinks = Boolean(links?.homepage || links?.repository || links?.documentation);
const hasLicense = true;
if (!hasRuntimeRequirements && !hasInstallSpecs && !hasDependencies && !hasLinks) {
if (!hasRuntimeRequirements && !hasInstallSpecs && !hasDependencies && !hasLinks && !hasLicense) {
return null;
}
return (
<div className="skill-hero-content">
<div className="skill-hero-panels">
<div className="border-t border-[color:var(--line)] pt-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
License
</h3>
<div className="flex flex-col gap-2">
<Badge variant="accent">{PLATFORM_SKILL_LICENSE}</Badge>
<div className="text-sm text-[color:var(--ink-soft)]">
<span>{PLATFORM_SKILL_LICENSE_SUMMARY}</span>
</div>
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Terms</strong>
<a
href={PLATFORM_SKILL_LICENSE_URL}
target="_blank"
rel="noopener noreferrer"
className="ml-1"
>
{PLATFORM_SKILL_LICENSE_URL}
</a>
</div>
</div>
</CardContent>
</Card>
{hasRuntimeRequirements ? (
<div className="skill-panel">
<h3 className="section-title text-[1rem] m-0">
Runtime requirements
</h3>
<div className="skill-panel-body">
{clawdis?.emoji ? <Badge>{clawdis.emoji} Clawdis</Badge> : null}
{osLabels.length ? (
<div className="stat">
<strong>OS</strong>
<span>{osLabels.join(" · ")}</span>
</div>
) : null}
{requirements?.bins?.length ? (
<div className="stat">
<strong>Bins</strong>
<span>{requirements.bins.join(", ")}</span>
</div>
) : null}
{requirements?.anyBins?.length ? (
<div className="stat">
<strong>Any bin</strong>
<span>{requirements.anyBins.join(", ")}</span>
</div>
) : null}
{requirements?.env?.length ? (
<div className="stat">
<strong>Env</strong>
<span>{requirements.env.join(", ")}</span>
</div>
) : null}
{requirements?.config?.length ? (
<div className="stat">
<strong>Config</strong>
<span>{requirements.config.join(", ")}</span>
</div>
) : null}
{clawdis?.primaryEnv ? (
<div className="stat">
<strong>Primary env</strong>
<span>{clawdis.primaryEnv}</span>
</div>
) : null}
{envVars.length > 0 ? (
<div className="stat">
<strong>Environment variables</strong>
<div className="flex flex-col gap-1 mt-1">
{envVars.map((env, index) => (
<div
key={`${env.name}-${index}`}
className="flex items-baseline gap-2"
>
<code className="text-[0.85rem]">{env.name}</code>
{env.required === false ? (
<span className="text-ink-soft text-[0.75rem]">
optional
</span>
) : env.required === true ? (
<span className="text-ink-accent text-[0.75rem]">
required
</span>
) : null}
{env.description ? (
<span className="text-ink-soft text-[0.8rem]">
{env.description}
</span>
) : null}
</div>
))}
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
Runtime requirements
</h3>
<div className="flex flex-col gap-2">
{clawdis?.emoji ? <Badge>{clawdis.emoji} Clawdis</Badge> : null}
{osLabels.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>OS</strong>
<span className="ml-1">{osLabels.join(" · ")}</span>
</div>
</div>
) : null}
</div>
</div>
) : null}
{hasDependencies ? (
<div className="skill-panel">
<h3 className="section-title text-[1rem] m-0">
Dependencies
</h3>
<div className="skill-panel-body">
{dependencies.map((dep, index) => (
<div key={`${dep.name}-${index}`} className="stat">
<div>
<strong>{dep.name}</strong>
<span className="text-ink-soft text-[0.85rem] ml-2">
{dep.type}
{dep.version ? ` ${dep.version}` : ""}
</span>
{dep.url ? (
<div className="text-[0.8rem] break-all">
<a href={dep.url} target="_blank" rel="noopener noreferrer">
{dep.url}
</a>
</div>
) : null}
{dep.repository && dep.repository !== dep.url ? (
<div className="text-[0.8rem]">
<a href={dep.repository} target="_blank" rel="noopener noreferrer">
Source
</a>
</div>
) : null}
) : null}
{requirements?.bins?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Bins</strong>
<span className="ml-1">{requirements.bins.join(", ")}</span>
</div>
</div>
))}
</div>
</div>
) : null}
{hasInstallSpecs ? (
<div className="skill-panel">
<h3 className="section-title text-[1rem] m-0">
Install
</h3>
<div className="skill-panel-body">
{installSpecs.map((spec, index) => {
const command = formatInstallCommand(spec);
return (
<div key={`${spec.id ?? spec.kind}-${index}`} className="stat">
<div>
<strong>{spec.label ?? formatInstallLabel(spec)}</strong>
{spec.bins?.length ? (
<div className="text-ink-soft text-[0.85rem]">
Bins: {spec.bins.join(", ")}
) : null}
{requirements?.anyBins?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Any bin</strong>
<span className="ml-1">{requirements.anyBins.join(", ")}</span>
</div>
) : null}
{requirements?.env?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Env</strong>
<span className="ml-1">{requirements.env.join(", ")}</span>
</div>
) : null}
{requirements?.config?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Config</strong>
<span className="ml-1">{requirements.config.join(", ")}</span>
</div>
) : null}
{clawdis?.primaryEnv ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Primary env</strong>
<span className="ml-1">{clawdis.primaryEnv}</span>
</div>
) : null}
{envVars.length > 0 ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Environment variables</strong>
<div className="mt-1 flex flex-col gap-1">
{envVars.map((env, index) => (
<div key={`${env.name}-${index}`} className="flex items-baseline gap-2">
<code className="text-[0.85rem]">{env.name}</code>
{env.required === false ? (
<span className="text-xs text-[color:var(--ink-soft)]">optional</span>
) : env.required === true ? (
<span className="text-xs text-[color:var(--accent)]">required</span>
) : null}
{env.description ? (
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
{env.description}
</span>
) : null}
</div>
) : null}
{command ? <code>{command}</code> : null}
))}
</div>
</div>
);
})}
</div>
</div>
) : null}
</div>
</CardContent>
</Card>
) : null}
{hasDependencies ? (
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
Dependencies
</h3>
<div className="flex flex-col gap-2">
{dependencies.map((dep, index) => (
<div
key={`${dep.name}-${index}`}
className="text-sm text-[color:var(--ink-soft)]"
>
<div>
<strong>{dep.name}</strong>
<span className="ml-2 text-[0.85rem] text-[color:var(--ink-soft)]">
{dep.type}
{dep.version ? ` ${dep.version}` : ""}
</span>
{dep.url ? (
<div className="break-all text-[0.8rem]">
<a href={dep.url} target="_blank" rel="noopener noreferrer">
{dep.url}
</a>
</div>
) : null}
{dep.repository && dep.repository !== dep.url ? (
<div className="text-[0.8rem]">
<a href={dep.repository} target="_blank" rel="noopener noreferrer">
Source
</a>
</div>
) : null}
</div>
</div>
))}
</div>
</CardContent>
</Card>
) : null}
{hasInstallSpecs ? (
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
Install
</h3>
<div className="flex flex-col gap-2">
{installSpecs.map((spec, index) => {
const command = formatInstallCommand(spec);
return (
<div
key={`${spec.id ?? spec.kind}-${index}`}
className="text-sm text-[color:var(--ink-soft)]"
>
<div>
<strong>{spec.label ?? formatInstallLabel(spec)}</strong>
{spec.bins?.length ? (
<div className="text-[0.85rem] text-[color:var(--ink-soft)]">
Bins: {spec.bins.join(", ")}
</div>
) : null}
{command ? (
<code className="mt-0.5 block font-mono text-xs">{command}</code>
) : null}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
) : null}
{hasLinks ? (
<div className="skill-panel">
<h3 className="section-title text-[1rem] m-0">
Links
</h3>
<div className="skill-panel-body">
{links?.homepage ? (
<div className="stat">
<strong>Homepage</strong>
<a href={links.homepage} target="_blank" rel="noopener noreferrer" className="break-all">
{links.homepage}
</a>
</div>
) : null}
{links?.repository ? (
<div className="stat">
<strong>Repository</strong>
<a href={links.repository} target="_blank" rel="noopener noreferrer" className="break-all">
{links.repository}
</a>
</div>
) : null}
{links?.documentation ? (
<div className="stat">
<strong>Docs</strong>
<a href={links.documentation} target="_blank" rel="noopener noreferrer">
{links.documentation}
</a>
</div>
) : null}
</div>
</div>
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
Links
</h3>
<div className="flex flex-col gap-2">
{links?.homepage ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Homepage</strong>
<a
href={links.homepage}
target="_blank"
rel="noopener noreferrer"
className="ml-1 break-all"
>
{links.homepage}
</a>
</div>
) : null}
{links?.repository ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Repository</strong>
<a
href={links.repository}
target="_blank"
rel="noopener noreferrer"
className="ml-1 break-all"
>
{links.repository}
</a>
</div>
) : null}
{links?.documentation ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Docs</strong>
<a
href={links.documentation}
target="_blank"
rel="noopener noreferrer"
className="ml-1"
>
{links.documentation}
</a>
</div>
) : null}
</div>
</CardContent>
</Card>
) : null}
</div>
</div>
-53
View File
@@ -1,53 +0,0 @@
import { Link } from "@tanstack/react-router";
import { Package, Star } from "lucide-react";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Badge } from "./ui/badge";
import { getSkillBadges } from "../lib/badges";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { timeAgo } from "../lib/timeAgo";
type SkillListItemProps = {
skill: PublicSkill;
ownerHandle?: string | null;
owner?: PublicPublisher | null;
};
export function SkillListItem({ skill, ownerHandle, owner }: SkillListItemProps) {
const handle = ownerHandle ?? owner?.handle ?? null;
const ownerSegment = handle?.trim() || String(skill.ownerPublisherId ?? skill.ownerUserId);
const href = `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(skill.slug)}`;
const badges = getSkillBadges(skill);
return (
<Link to={href} className="skill-list-item">
<MarketplaceIcon kind="skill" label={skill.displayName} />
<div className="skill-list-item-body">
<div className="skill-list-item-main">
{handle ? (
<>
<span className="skill-list-item-owner">@{handle}</span>
<span className="skill-list-item-sep">/</span>
</>
) : null}
<span className="skill-list-item-name">{skill.displayName}</span>
{badges.map((b) => (
<Badge key={b} variant="compact">
{b}
</Badge>
))}
</div>
{skill.summary ? <p className="skill-list-item-summary">{skill.summary}</p> : null}
<div className="skill-list-item-meta">
<span className="skill-list-item-meta-item">Updated {timeAgo(skill.updatedAt)}</span>
<span className="skill-list-item-meta-item">
<Star size={14} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
</span>
<span className="skill-list-item-meta-item">
<Package size={14} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
</span>
</div>
</div>
</Link>
);
}
-121
View File
@@ -1,121 +0,0 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_SUMMARY,
} from "clawhub-schema/licenseConstants";
import { Calendar, Download, Package, Scale, Star, Tag } from "lucide-react";
import type { Id } from "../../convex/_generated/dataModel";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { getRuntimeEnv } from "../lib/runtimeEnv";
import { timeAgo } from "../lib/timeAgo";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { UserBadge } from "./UserBadge";
type SkillMetadataSidebarProps = {
skill: PublicSkill;
latestVersion: { version?: string; _id: Id<"skillVersions"> } | null;
owner: PublicPublisher | null;
ownerHandle: string | null;
clawdis?: ClawdisSkillMetadata;
osLabels: string[];
tagEntries: Array<[string, Id<"skillVersions">]>;
isMalwareBlocked?: boolean;
isRemoved?: boolean;
nixPlugin?: string;
};
export function SkillMetadataSidebar({
skill,
latestVersion,
owner,
ownerHandle,
clawdis: _clawdis,
osLabels,
tagEntries,
isMalwareBlocked,
isRemoved,
nixPlugin,
}: SkillMetadataSidebarProps) {
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
const showDownload = !nixPlugin && !isMalwareBlocked && !isRemoved;
return (
<div className="detail-meta-bar">
{/* Stats row */}
<div className="meta-bar-stats">
<div className="meta-stat">
<Download size={14} aria-hidden="true" />
<span className="meta-stat-value">{formatCompactStat(skill.stats.downloads)}</span>
<span className="meta-stat-label">downloads</span>
</div>
<div className="meta-stat">
<Star size={14} aria-hidden="true" />
<span className="meta-stat-value">{formatCompactStat(skill.stats.stars)}</span>
<span className="meta-stat-label">stars</span>
</div>
<div className="meta-stat">
<Package size={14} aria-hidden="true" />
<span className="meta-stat-value">{formatCompactStat(skill.stats.versions ?? 0)}</span>
<span className="meta-stat-label">versions</span>
</div>
</div>
{/* Details row */}
<div className="meta-bar-details">
<div className="meta-detail">
<Calendar size={12} aria-hidden="true" />
<span>Updated {timeAgo(skill.updatedAt)}</span>
</div>
{latestVersion?.version ? (
<div className="meta-detail">
<Tag size={12} aria-hidden="true" />
<span>v{latestVersion.version}</span>
</div>
) : null}
<div className="meta-detail">
<Scale size={12} aria-hidden="true" />
<span>{PLATFORM_SKILL_LICENSE}</span>
</div>
{osLabels.length > 0 ? (
<div className="meta-detail">
<span>{osLabels.join(", ")}</span>
</div>
) : null}
</div>
{/* Tags and Publisher row */}
<div className="meta-bar-footer">
<div className="meta-bar-publisher">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix=""
size="sm"
showName
/>
</div>
{tagEntries.length > 0 ? (
<div className="meta-bar-tags">
{tagEntries.map(([tag]) => (
<Badge key={tag} variant="compact">
{tag}
</Badge>
))}
</div>
) : null}
{showDownload ? (
<Button asChild variant="primary" size="sm">
<a href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}>
<Download size={14} aria-hidden="true" />
Download
</a>
</Button>
) : null}
</div>
</div>
);
}
+2 -3
View File
@@ -1,5 +1,4 @@
import { useState } from "react";
import { Badge } from "./ui/badge";
type LlmAnalysisDimension = {
name: string;
@@ -389,9 +388,9 @@ export function SecurityScanResults({
<div className="scan-findings-title">Capability signals</div>
<div className="scan-capability-tags">
{visibleCapabilityTags.map((tag) => (
<Badge key={tag} className="scan-capability-tag">
<span key={tag} className="tag scan-capability-tag">
{SKILL_CAPABILITY_LABELS[tag] ?? tag}
</Badge>
</span>
))}
</div>
<div className="scan-capability-note">
+12 -8
View File
@@ -1,6 +1,5 @@
import { Link } from "@tanstack/react-router";
import type { ReactNode } from "react";
import { MarketplaceIcon } from "./MarketplaceIcon";
import type { PublicSoul } from "../lib/publicUser";
type SoulCardProps = {
@@ -11,13 +10,18 @@ type SoulCardProps = {
export function SoulCard({ soul, summaryFallback, meta }: SoulCardProps) {
return (
<Link to="/souls/$slug" params={{ slug: soul.slug }} className="card skill-card">
<div className="skill-card-header">
<MarketplaceIcon kind="soul" label={soul.displayName} size="md" />
<h3 className="skill-card-title">{soul.displayName}</h3>
</div>
<p className="skill-card-summary">{soul.summary ?? summaryFallback}</p>
<div className="skill-card-footer">{meta}</div>
<Link
to="/souls/$slug"
params={{ slug: soul.slug }}
className="group flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[22px] no-underline transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_28px_rgba(29,26,23,0.12)]"
>
<h3 className="font-display text-base font-bold text-[color:var(--ink)] group-hover:text-[color:var(--accent)]">
{soul.displayName}
</h3>
<p className="line-clamp-2 text-sm leading-relaxed text-[color:var(--ink-soft)]">
{soul.summary ?? summaryFallback}
</p>
<div className="mt-auto flex items-center gap-3 pt-2">{meta}</div>
</Link>
);
}
+1 -88
View File
@@ -1,12 +1,5 @@
import { Package, Star, Download } from "lucide-react";
import { useEffect, useState } from "react";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import { convexHttp } from "../convex/client";
import { hasOwnProperty } from "../lib/hasOwnProperty";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicUser } from "../lib/publicUser";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type UserBadgeProps = {
user: PublicUser | PublicPublisher | null | undefined;
@@ -47,14 +40,7 @@ export function UserBadge({
displayName!.toLowerCase() !== handle!.toLowerCase();
const initial = (displayName ?? handle ?? "u").charAt(0).toUpperCase();
// Resolve userId for stats query — PublicUser has _id directly,
// PublicPublisher has linkedUserId
const userId =
user && hasOwnProperty(user, "kind")
? (user as PublicPublisher).linkedUserId ?? null
: user?._id ?? null;
const badge = (
return (
<span className={`user-badge user-badge-${size}`}>
{prefix ? <span className="user-badge-prefix">{prefix}</span> : null}
<span className="user-avatar" aria-hidden="true">
@@ -81,77 +67,4 @@ export function UserBadge({
)}
</span>
);
if (!userId) return badge;
return (
<Tooltip>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<UserStatsTooltipContent userId={userId} displayName={displayName} handle={handle} />
</Tooltip>
);
}
type HoverStats = { publishedSkills: number; totalStars: number; totalDownloads: number };
function UserStatsTooltipContent({
userId,
displayName,
handle,
}: {
userId: string;
displayName: string | null;
handle: string | null;
}) {
const [stats, setStats] = useState<HoverStats | null>(null);
const [fetched, setFetched] = useState(false);
// One-shot fetch on mount (tooltip content only mounts when open)
useEffect(() => {
if (fetched) return;
setFetched(true);
void convexHttp
.query(api.users.getHoverStats, { userId: userId as Id<"users"> })
.then(setStats)
.catch(() => {});
}, [userId, fetched]);
return (
<TooltipContent
side="top"
className="min-w-[140px] p-0"
onPointerDownOutside={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-space-1 px-3 py-2">
{displayName && (
<span className="text-fs-sm font-semibold text-ink truncate max-w-[180px]">
{displayName}
</span>
)}
{handle && (
<span className="text-fs-xs text-ink-soft">@{handle}</span>
)}
</div>
<div className="border-t border-line flex items-center gap-space-3 px-3 py-2">
{stats === null ? (
<span className="text-fs-xs text-ink-soft">Loading...</span>
) : (
<>
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Published skills">
<Package size={12} />
{formatCompactStat(stats.publishedSkills)}
</span>
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Stars received">
<Star size={12} />
{formatCompactStat(stats.totalStars)}
</span>
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Total downloads">
<Download size={12} />
{formatCompactStat(stats.totalDownloads)}
</span>
</>
)}
</div>
</TooltipContent>
);
}
-31
View File
@@ -1,31 +0,0 @@
import { Link } from "@tanstack/react-router";
import { MarketplaceIcon } from "./MarketplaceIcon";
import type { PublicUser } from "../lib/publicUser";
type UserListItemProps = {
user: PublicUser;
};
export function UserListItem({ user }: UserListItemProps) {
const handle = user.handle?.trim();
if (!handle) return null;
const displayName = user.displayName ?? user.name ?? handle;
return (
<Link to="/u/$handle" params={{ handle }} className="skill-list-item user-list-item" aria-label={`User: ${displayName}`}>
<MarketplaceIcon kind="user" label={displayName} imageUrl={user.image} />
<div className="skill-list-item-body">
<div className="skill-list-item-main">
<span className="skill-list-item-name">{displayName}</span>
<span className="skill-list-item-owner">@{handle}</span>
</div>
<p className="skill-list-item-summary">{user.bio?.trim() || "Builder on ClawHub."}</p>
<div className="skill-list-item-meta">
<span className="skill-list-item-meta-item">User</span>
<span className="skill-list-item-meta-item">Profile</span>
</div>
</div>
</Link>
);
}
+14 -14
View File
@@ -2,23 +2,23 @@ import * as React from "react";
import { cn } from "../../lib/utils";
interface ContainerProps extends React.HTMLAttributes<HTMLDivElement> {
size?: "default" | "narrow" | "wide";
size?: "default" | "narrow" | "wide";
}
const Container = React.forwardRef<HTMLDivElement, ContainerProps>(
({ className, size = "default", ...props }, ref) => (
<div
ref={ref}
className={cn(
"mx-auto w-full px-4 sm:px-6 lg:px-7",
size === "default" && "max-w-page-max",
size === "narrow" && "max-w-page-narrow",
size === "wide" && "w-full",
className,
)}
{...props}
/>
),
({ className, size = "default", ...props }, ref) => (
<div
ref={ref}
className={cn(
"mx-auto w-full px-4 sm:px-6 lg:px-7",
size === "default" && "max-w-[1200px]",
size === "narrow" && "max-w-[900px]",
size === "wide" && "max-w-[1400px]",
className,
)}
{...props}
/>
),
);
Container.displayName = "Container";
@@ -2,7 +2,7 @@ import { Skeleton } from "../ui/skeleton";
export function DashboardSkeleton() {
return (
<div className="mx-auto max-w-page-max px-7 py-10">
<div className="mx-auto max-w-[1200px] px-7 py-10">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<Skeleton className="h-8 w-52" />
@@ -2,7 +2,7 @@ import { Skeleton } from "../ui/skeleton";
export function SkillDetailSkeleton() {
return (
<div className="mx-auto max-w-page-max px-7 py-10">
<div className="mx-auto max-w-[1200px] px-7 py-10">
{/* Breadcrumb */}
<Skeleton className="mb-6 h-4 w-48" />
+31 -10
View File
@@ -10,16 +10,37 @@ const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
<span
ref={ref}
className={cn(
// Base styles
"inline-flex items-center gap-1.5 rounded-[var(--radius-pill)] text-fs-sm font-semibold",
// Variant styles — all token-driven, no dark: overrides needed
variant === "default" && "bg-hover-bg px-3 py-1 text-ink-soft border border-line",
variant === "accent" && "bg-active-bg px-3 py-1 text-accent-deep border border-line",
variant === "compact" && "bg-hover-bg px-2.5 py-0.5 text-fs-xs text-ink-soft border border-line",
variant === "pending" && "bg-status-warning-bg px-3 py-1 text-status-warning-fg border border-line",
variant === "success" && "bg-status-success-bg px-3 py-1 text-status-success-fg border border-line",
variant === "warning" && "bg-status-warning-bg px-3 py-1 text-status-warning-fg border border-line",
variant === "destructive" && "bg-status-error-bg px-3 py-1 text-status-error-fg border border-line",
// Base styles matching .tag
"inline-flex items-center gap-1.5 rounded-[var(--radius-pill)] text-[0.8rem] font-semibold",
// Variant styles
variant === "default" && [
"bg-[rgba(43,198,164,0.16)] px-3 py-1 text-[#1a6b5b]",
"dark:bg-[rgba(232,106,71,0.2)] dark:text-[#ffd0bf]",
],
variant === "accent" && [
"bg-[rgba(255,107,74,0.16)] px-3 py-1 text-[color:var(--accent-deep)]",
"dark:bg-[rgba(232,106,71,0.24)] dark:text-[#ffd0bf]",
],
variant === "compact" && [
"bg-[rgba(43,198,164,0.16)] px-2.5 py-0.5 text-[0.72rem] text-[#1a6b5b]",
"dark:bg-[rgba(232,106,71,0.2)] dark:text-[#ffd0bf]",
],
variant === "pending" && [
"bg-[rgba(240,196,106,0.2)] px-3 py-1 text-[#8a6914]",
"dark:bg-[rgba(243,201,122,0.18)] dark:text-[color:var(--gold)]",
],
variant === "success" && [
"bg-emerald-100 px-3 py-1 text-emerald-700",
"dark:bg-emerald-900/30 dark:text-emerald-300",
],
variant === "warning" && [
"bg-amber-100 px-3 py-1 text-amber-700",
"dark:bg-amber-900/30 dark:text-amber-300",
],
variant === "destructive" && [
"bg-red-100 px-3 py-1 text-red-700",
"dark:bg-red-900/30 dark:text-red-300",
],
className,
)}
{...props}
+4 -4
View File
@@ -33,15 +33,15 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
"disabled:pointer-events-none disabled:opacity-60",
// Hover lift
"hover:not-disabled:-translate-y-px hover:not-disabled:shadow-hover",
// Hover lift (matches .btn:hover)
"hover:not-disabled:-translate-y-px hover:not-disabled:shadow-[0_10px_20px_rgba(29,26,23,0.12)]",
// Variant styles
variant === "default" &&
"border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)]",
variant === "primary" &&
"border border-accent bg-accent/10 text-[color:var(--ink)]",
"border-none bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] text-white dark:from-[#c35640] dark:to-[#953827] dark:shadow-[0_10px_22px_rgba(58,23,16,0.42),inset_0_1px_0_rgba(255,201,184,0.18)]",
variant === "destructive" &&
"border border-status-error-fg/20 bg-status-error-bg text-status-error-fg hover:not-disabled:bg-active-bg",
"border border-red-300/40 bg-red-50 text-red-700 hover:not-disabled:bg-red-100 dark:border-red-500/30 dark:bg-red-950/50 dark:text-red-300",
variant === "ghost" &&
"border-transparent bg-transparent text-[color:var(--ink-soft)] hover:not-disabled:bg-[color:var(--surface-muted)] hover:not-disabled:text-[color:var(--ink)] hover:not-disabled:shadow-none hover:not-disabled:translate-y-0",
variant === "outline" &&
+1 -1
View File
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
ref={ref}
className={cn(
// Matches .card
"flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-space-5 transition-all duration-200 ease-out",
"flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[22px] transition-all duration-200 ease-out",
className,
)}
{...props}
+2 -2
View File
@@ -16,7 +16,7 @@ const DialogOverlay = React.forwardRef<
ref={ref}
className={cn(
// Matches .report-dialog-backdrop
"fixed inset-0 z-80 grid place-items-center bg-overlay-bg p-5 backdrop-blur-[3px]",
"fixed inset-0 z-80 grid place-items-center bg-[rgba(21,24,35,0.42)] p-5 backdrop-blur-[3px]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
className,
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
ref={ref}
className={cn(
// Matches .report-dialog
"fixed top-1/2 left-1/2 z-80 grid w-[min(100%,560px)] -translate-x-1/2 -translate-y-1/2 gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5 shadow-dialog",
"fixed top-1/2 left-1/2 z-80 grid w-[min(100%,560px)] -translate-x-1/2 -translate-y-1/2 gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5 shadow-[0_24px_50px_rgba(18,22,34,0.24)]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,
+2 -2
View File
@@ -18,7 +18,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
"z-50 min-w-[180px] rounded-xl border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
className,
)}
{...props}
@@ -34,7 +34,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"flex cursor-pointer select-none items-center gap-2 rounded-[var(--radius-sm)] px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"flex cursor-pointer select-none items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
+10 -6
View File
@@ -7,12 +7,16 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
ref={ref}
type={type}
className={cn(
// Base styles
"w-full min-h-[44px] rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-input-border bg-input-bg",
"placeholder:text-input-placeholder",
// Focus
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
// Base styles matching .form-input
"w-full min-h-[44px] rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)]",
"placeholder:text-[rgba(88,115,133,0.72)]",
// Focus styles matching .form-input:focus
"focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)]",
// Dark mode matching [data-theme="dark"] .form-input
"dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]",
"dark:placeholder:text-[rgba(184,205,216,0.68)]",
"dark:focus:border-[rgba(255,131,95,0.75)] dark:focus:shadow-[0_0_0_3px_rgba(255,131,95,0.2)]",
// Disabled
"disabled:cursor-not-allowed disabled:opacity-60",
className,
+3 -2
View File
@@ -10,8 +10,9 @@ const Label = React.forwardRef<
ref={ref}
className={cn(
// Matches .form-label
"text-fs-xs font-bold uppercase tracking-[0.14em]",
"text-label-fg",
"text-[0.74rem] font-bold uppercase tracking-[0.14em]",
"text-[rgba(70,95,113,0.9)]",
"dark:text-[rgba(206,227,238,0.76)]",
"peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
+8 -7
View File
@@ -14,11 +14,12 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
// Matches form input token styling
"flex w-full min-h-[44px] items-center justify-between rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-sm text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-input-border bg-input-bg",
"placeholder:text-input-placeholder",
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
// Matches .form-input styling for consistency
"flex w-full min-h-[44px] items-center justify-between rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-sm text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)]",
"placeholder:text-[rgba(88,115,133,0.72)]",
"focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)]",
"dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]",
"disabled:cursor-not-allowed disabled:opacity-60",
className,
)}
@@ -68,7 +69,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] shadow-[var(--shadow)]",
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] shadow-[var(--shadow)]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
@@ -114,7 +115,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-[var(--radius-sm)] py-2 pr-8 pl-3 text-sm font-semibold outline-none transition-colors",
"relative flex w-full cursor-pointer select-none items-center rounded-lg py-2 pr-8 pl-3 text-sm font-semibold outline-none transition-colors",
"focus:bg-[color:var(--surface-muted)] focus:text-[color:var(--ink)]",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
+1 -1
View File
@@ -15,7 +15,7 @@ const SheetOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-80 bg-overlay-bg backdrop-blur-[3px]",
"fixed inset-0 z-80 bg-[rgba(21,24,35,0.42)] backdrop-blur-[3px]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
className,
-32
View File
@@ -1,32 +0,0 @@
import * as SwitchPrimitive from "@radix-ui/react-switch";
import * as React from "react";
import { cn } from "../../lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
ref={ref}
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent",
"transition-colors duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
"disabled:cursor-not-allowed disabled:opacity-50",
"data-[state=checked]:bg-[color:var(--accent)] data-[state=unchecked]:bg-[color:var(--surface-muted)]",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm ring-0",
"transition-transform duration-200 ease-out",
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
));
Switch.displayName = SwitchPrimitive.Root.displayName;
export { Switch };
+9 -5
View File
@@ -8,12 +8,16 @@ const Textarea = React.forwardRef<
<textarea
ref={ref}
className={cn(
// Base styles
"w-full min-h-[100px] resize-y rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-input-border bg-input-bg",
"placeholder:text-input-placeholder",
// Base styles matching .form-input
"w-full min-h-[100px] resize-y rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)]",
"placeholder:text-[rgba(88,115,133,0.72)]",
// Focus
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
"focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)]",
// Dark mode
"dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]",
"dark:placeholder:text-[rgba(184,205,216,0.68)]",
"dark:focus:border-[rgba(255,131,95,0.75)] dark:focus:shadow-[0_0_0_3px_rgba(255,131,95,0.2)]",
// Disabled
"disabled:cursor-not-allowed disabled:opacity-60",
className,
+2 -2
View File
@@ -9,7 +9,7 @@ const ToggleGroup = React.forwardRef<
<ToggleGroupPrimitive.Root
ref={ref}
className={cn(
"inline-flex h-[38px] items-center gap-0.5 rounded-[var(--radius-pill)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[3px]",
"inline-flex h-[38px] items-center gap-0.5 rounded-full border border-[color:var(--line)] bg-[color:var(--surface)] p-[3px]",
className,
)}
{...props}
@@ -24,7 +24,7 @@ const ToggleGroupItem = React.forwardRef<
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
"inline-flex h-[30px] w-[30px] items-center justify-center rounded-[var(--radius-pill)] text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] data-[state=on]:bg-accent data-[state=on]:text-accent-fg",
"inline-flex h-[30px] w-[30px] items-center justify-center rounded-full text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] data-[state=on]:bg-[color:var(--accent)] data-[state=on]:text-white",
className,
)}
{...props}
+9 -9
View File
@@ -1,19 +1,19 @@
export type SkillCategory = {
slug: string;
label: string;
icon: string;
keywords: string[];
};
export const SKILL_CATEGORIES: SkillCategory[] = [
{ slug: "mcp-tools", label: "MCP Tools", icon: "plug", keywords: ["mcp", "tool", "server"] },
{ slug: "prompts", label: "Prompts", icon: "message-square", keywords: ["prompt", "template", "system"] },
{ slug: "workflows", label: "Workflows", icon: "git-branch", keywords: ["workflow", "pipeline", "chain"] },
{ slug: "dev-tools", label: "Dev Tools", icon: "wrench", keywords: ["dev", "debug", "lint", "test", "build"] },
{ slug: "data", label: "Data & APIs", icon: "database", keywords: ["api", "data", "fetch", "http", "rest", "graphql"] },
{ slug: "security", label: "Security", icon: "shield", keywords: ["security", "scan", "auth", "encrypt"] },
{ slug: "automation", label: "Automation", icon: "zap", keywords: ["auto", "cron", "schedule", "bot"] },
{ slug: "other", label: "Other", icon: "package", keywords: [] },
{ slug: "mcp-tools", label: "MCP Tools", keywords: ["mcp", "tool", "server"] },
{ slug: "prompts", label: "Prompts", keywords: ["prompt", "template", "system"] },
{ slug: "workflows", label: "Workflows", keywords: ["workflow", "pipeline", "chain"] },
{ slug: "dev-tools", label: "Dev Tools", keywords: ["dev", "debug", "lint", "test", "build"] },
{ slug: "data", label: "Data & APIs", keywords: ["api", "data", "fetch", "http", "rest", "graphql"] },
{ slug: "security", label: "Security", keywords: ["security", "scan", "auth", "encrypt"] },
{ slug: "automation", label: "Automation", keywords: ["auto", "cron", "schedule", "bot"] },
{ slug: "other", label: "Other", keywords: [] },
];
export const ALL_CATEGORY_KEYWORDS = SKILL_CATEGORIES.flatMap((c) => c.keywords);
-424
View File
@@ -1,424 +0,0 @@
/**
* Lightweight custom theme support for ClawHub.
*
* Accepts tweakcn theme URLs, bare tweakcn names, tweakcn JSON payloads,
* or raw CSS variable blocks, then maps them into ClawHub's token system.
*/
const SUPPORTED_COLOR_VARS = [
"background",
"foreground",
"card",
"card-foreground",
"popover",
"popover-foreground",
"primary",
"primary-foreground",
"secondary",
"secondary-foreground",
"muted",
"muted-foreground",
"accent",
"accent-foreground",
"destructive",
"destructive-foreground",
"border",
"input",
"ring",
"info",
"info-foreground",
"success",
"success-foreground",
"warning",
"warning-foreground",
] as const;
const SUPPORTED_DESIGN_VARS = ["radius"] as const;
const SUPPORTED_FONT_VARS = ["font-sans", "font-serif", "font-mono"] as const;
const ALL_SUPPORTED_VARS = new Set<string>([
...SUPPORTED_COLOR_VARS,
...SUPPORTED_DESIGN_VARS,
...SUPPORTED_FONT_VARS,
]);
const CUSTOM_THEME_STORAGE_KEY = "clawhub-custom-theme";
const CUSTOM_THEME_STYLE_ID = "clawhub-custom-theme-style";
const CUSTOM_THEME_FONT_LINK_ID = "clawhub-custom-theme-fonts";
const SYSTEM_FONTS = new Set([
"system-ui",
"-apple-system",
"blinkmacsystemfont",
"segoe ui",
"roboto",
"helvetica neue",
"arial",
"sans-serif",
"serif",
"monospace",
"sf mono",
"sfmono-regular",
"consolas",
"liberation mono",
"menlo",
"courier new",
"dm sans",
"georgia",
"times new roman",
"times",
"ui-monospace",
"ui-sans-serif",
"ui-serif",
"bricolage grotesque",
"manrope",
"ibm plex mono",
]);
export interface CustomThemeData {
name?: string | undefined;
source?: string | undefined;
light: Record<string, string>;
dark: Record<string, string>;
}
function hasDom(): boolean {
return (
typeof document !== "undefined" &&
typeof document.getElementById === "function" &&
typeof document.createElement === "function"
);
}
function extractBraceContent(css: string, startAfterBrace: number): string {
let depth = 1;
let i = startAfterBrace;
while (i < css.length && depth > 0) {
if (css[i] === "{") depth++;
else if (css[i] === "}") depth--;
i++;
}
return css.substring(startAfterBrace, i - 1);
}
function extractVariables(block: string): Record<string, string> {
const vars: Record<string, string> = {};
const regex = /--([\w-]+)\s*:\s*([^;]+);/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(block)) !== null) {
const name = match[1]?.trim();
const value = match[2]?.trim();
if (!name || !value || !ALL_SUPPORTED_VARS.has(name)) continue;
vars[name] = value;
}
return vars;
}
function filterSupported(vars: Record<string, string>): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(vars)) {
if (ALL_SUPPORTED_VARS.has(key)) out[key] = value;
}
return out;
}
export function parseThemeCSS(css: string): CustomThemeData {
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, "");
const light: Record<string, string> = {};
const dark: Record<string, string> = {};
const rootRegex = /:root\s*\{/g;
let match: RegExpExecArray | null;
while ((match = rootRegex.exec(cleaned)) !== null) {
const start = match.index + match[0].length;
Object.assign(light, extractVariables(extractBraceContent(cleaned, start)));
}
const darkRegex = /\.dark\s*\{/g;
while ((match = darkRegex.exec(cleaned)) !== null) {
const start = match.index + match[0].length;
Object.assign(dark, extractVariables(extractBraceContent(cleaned, start)));
}
if (Object.keys(light).length === 0 && Object.keys(dark).length === 0) {
const vars = extractVariables(cleaned);
Object.assign(light, vars);
Object.assign(dark, vars);
}
return { light, dark };
}
export function parseTweakcnJSON(json: unknown): CustomThemeData {
if (!json || typeof json !== "object") {
throw new Error("Invalid theme JSON");
}
const obj = json as Record<string, unknown>;
const name = typeof obj.name === "string" ? obj.name : undefined;
const cssVars = obj.cssVars as Record<string, Record<string, string>> | undefined;
if (!cssVars || typeof cssVars !== "object") {
throw new Error('Theme JSON missing "cssVars" object');
}
const light = filterSupported({ ...cssVars.theme, ...cssVars.light });
const dark = filterSupported({ ...cssVars.theme, ...cssVars.dark });
if (Object.keys(light).length === 0 && Object.keys(dark).length === 0) {
throw new Error("No supported theme variables found in JSON");
}
return { name, light, dark };
}
const TWEAKCN_URL_PATTERNS = [
/^https?:\/\/(?:www\.)?tweakcn\.com\/r\/themes\/([^/?#]+)/,
/^https?:\/\/(?:www\.)?tweakcn\.com\/themes\/([^/?#]+)/,
/^https?:\/\/(?:www\.)?tweakcn\.com\/editor\/theme\?theme=([^&#]+)/,
];
export function isTweakcnURL(input: string): boolean {
const trimmed = input.trim();
return TWEAKCN_URL_PATTERNS.some((re) => re.test(trimmed));
}
function extractTweakcnThemeId(url: string): string | null {
const trimmed = url.trim();
for (const re of TWEAKCN_URL_PATTERNS) {
const match = trimmed.match(re);
if (match?.[1]) return match[1];
}
return null;
}
function isBareThemeName(input: string): boolean {
return /^[a-zA-Z0-9][\w-]*$/.test(input);
}
export async function fetchTweakcnTheme(urlOrName: string): Promise<CustomThemeData> {
const themeId = isBareThemeName(urlOrName) ? urlOrName : extractTweakcnThemeId(urlOrName);
if (!themeId) {
throw new Error("Could not extract theme ID from URL");
}
const response = await fetch(`https://tweakcn.com/r/themes/${encodeURIComponent(themeId)}`);
if (!response.ok) {
throw new Error(`Failed to fetch theme: ${response.status} ${response.statusText}`);
}
return parseTweakcnJSON(await response.json());
}
export async function parseThemeInput(input: string): Promise<CustomThemeData> {
const trimmed = input.trim();
if (isTweakcnURL(trimmed)) {
return fetchTweakcnTheme(trimmed);
}
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
return parseTweakcnJSON(JSON.parse(trimmed));
} catch {
// fall through to CSS parsing
}
}
if (isBareThemeName(trimmed)) {
return fetchTweakcnTheme(trimmed);
}
const parsed = parseThemeCSS(trimmed);
if (Object.keys(parsed.light).length === 0 && Object.keys(parsed.dark).length === 0) {
throw new Error("No theme variables found. Paste CSS, a tweakcn theme URL, or a theme name.");
}
return parsed;
}
function extractGoogleFonts(theme: CustomThemeData): string[] {
const fonts = new Set<string>();
for (const mode of [theme.light, theme.dark]) {
for (const key of SUPPORTED_FONT_VARS) {
const value = mode[key];
if (!value) continue;
const families = value.split(",").map((part) => part.trim().replace(/^["']|["']$/g, ""));
for (const family of families) {
if (family && !SYSTEM_FONTS.has(family.toLowerCase())) {
fonts.add(family);
}
}
}
}
return Array.from(fonts);
}
function loadGoogleFonts(fonts: string[]): void {
if (!hasDom()) return;
document.getElementById(CUSTOM_THEME_FONT_LINK_ID)?.remove();
if (fonts.length === 0) return;
const families = fonts.map((font) => `family=${font.replace(/\s+/g, "+")}:wght@300..800`).join("&");
const link = document.createElement("link");
link.id = CUSTOM_THEME_FONT_LINK_ID;
link.rel = "stylesheet";
link.href = `https://fonts.googleapis.com/css2?${families}&display=swap`;
document.head.appendChild(link);
}
function unloadGoogleFonts(): void {
if (!hasDom()) return;
document.getElementById(CUSTOM_THEME_FONT_LINK_ID)?.remove();
}
function getVar(vars: Record<string, string>, key: string, fallback: string): string {
return vars[key] ?? fallback;
}
function buildModeLines(vars: Record<string, string>): string[] {
const background = getVar(vars, "background", "var(--bg)");
const foreground = getVar(vars, "foreground", "var(--ink)");
const card = getVar(vars, "card", background);
const secondary = getVar(vars, "secondary", card);
const muted = getVar(vars, "muted", secondary);
const primary = getVar(vars, "primary", "var(--accent)");
const primaryForeground = getVar(vars, "primary-foreground", "var(--accent-fg)");
const accent = getVar(vars, "accent", primary);
const border = getVar(vars, "border", "var(--line)");
const input = getVar(vars, "input", border);
const ring = getVar(vars, "ring", primary);
const destructive = getVar(vars, "destructive", "var(--status-error-fg)");
const success = getVar(vars, "success", "var(--seafoam)");
const warning = getVar(vars, "warning", "var(--gold)");
const mutedForeground = getVar(vars, "muted-foreground", foreground);
const radius = vars.radius;
const fontSans = vars["font-sans"];
const fontMono = vars["font-mono"];
const lines = [
` --bg: ${background};`,
` --bg-soft: ${muted};`,
` --surface: ${card};`,
` --surface-muted: ${secondary};`,
` --nav-bg: color-mix(in srgb, ${background} 96%, transparent);`,
` --ink: ${foreground};`,
` --ink-soft: ${mutedForeground};`,
` --accent: ${primary};`,
` --accent-fg: ${primaryForeground};`,
` --accent-deep: ${accent};`,
` --accent-subtle: color-mix(in srgb, ${primary} 14%, transparent);`,
` --seafoam: ${success};`,
` --gold: ${warning};`,
` --line: color-mix(in srgb, ${border} 72%, transparent);`,
` --border-ui: color-mix(in srgb, ${border} 90%, transparent);`,
` --border-ui-hover: color-mix(in srgb, ${ring} 50%, ${border});`,
` --border-ui-active: color-mix(in srgb, ${ring} 70%, ${border});`,
` --input-border: ${input};`,
` --input-bg: ${card};`,
` --input-placeholder: color-mix(in srgb, ${mutedForeground} 72%, transparent);`,
` --input-focus-border: ${ring};`,
` --input-focus-ring: color-mix(in srgb, ${ring} 18%, transparent);`,
` --label-fg: color-mix(in srgb, ${foreground} 82%, transparent);`,
` --hover-bg: color-mix(in srgb, ${secondary} 72%, transparent);`,
` --active-bg: color-mix(in srgb, ${primary} 12%, transparent);`,
` --overlay-bg: color-mix(in srgb, ${background} 76%, transparent);`,
` --status-success-bg: color-mix(in srgb, ${success} 14%, transparent);`,
` --status-success-fg: ${success};`,
` --status-warning-bg: color-mix(in srgb, ${warning} 14%, transparent);`,
` --status-warning-fg: ${warning};`,
` --status-error-bg: color-mix(in srgb, ${destructive} 14%, transparent);`,
` --status-error-fg: ${destructive};`,
` --diff-added: ${success};`,
` --diff-added-strong: ${success};`,
` --diff-removed: ${destructive};`,
` --diff-removed-strong: ${destructive};`,
` --diff-diagonal: color-mix(in srgb, ${primary} 12%, transparent);`,
];
if (radius) {
lines.push(` --r-lg: ${radius};`);
lines.push(` --r-md: ${radius};`);
lines.push(` --r-sm: ${radius};`);
}
if (fontSans) {
lines.push(` --font-display: ${fontSans};`);
lines.push(` --font-body: ${fontSans};`);
}
if (fontMono) {
lines.push(` --font-mono: ${fontMono};`);
}
return lines;
}
function buildCustomThemeCSS(theme: CustomThemeData): string {
return [
":root.theme-custom {",
...buildModeLines(theme.light),
"}",
"",
":root.theme-custom.dark {",
...buildModeLines(theme.dark),
"}",
].join("\n");
}
export function applyCustomTheme(theme: CustomThemeData): void {
if (!hasDom()) return;
let styleEl = document.getElementById(CUSTOM_THEME_STYLE_ID) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = CUSTOM_THEME_STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = buildCustomThemeCSS(theme);
document.documentElement.classList.add("theme-custom");
loadGoogleFonts(extractGoogleFonts(theme));
}
export function removeCustomTheme(): void {
if (!hasDom()) return;
document.getElementById(CUSTOM_THEME_STYLE_ID)?.remove();
document.documentElement.classList.remove("theme-custom");
unloadGoogleFonts();
}
export function getStoredCustomTheme(): CustomThemeData | null {
if (typeof localStorage === "undefined") return null;
try {
const raw = localStorage.getItem(CUSTOM_THEME_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as CustomThemeData;
if (parsed && typeof parsed.light === "object" && typeof parsed.dark === "object") {
return parsed;
}
} catch {
// ignore bad storage payloads
}
return null;
}
export function setStoredCustomTheme(theme: CustomThemeData): void {
if (typeof localStorage === "undefined") return;
localStorage.setItem(CUSTOM_THEME_STORAGE_KEY, JSON.stringify(theme));
}
export function clearStoredCustomTheme(): void {
if (typeof localStorage === "undefined") return;
localStorage.removeItem(CUSTOM_THEME_STORAGE_KEY);
}
export function syncCustomThemeFromStorage(): void {
const theme = getStoredCustomTheme();
if (theme) {
applyCustomTheme(theme);
return;
}
removeCustomTheme();
}
-4
View File
@@ -1,4 +0,0 @@
/** Standardized icon sizes for consistent sizing across the app */
export const ICON_SM = 14;
export const ICON_MD = 16;
export const ICON_LG = 24;
-234
View File
@@ -1,234 +0,0 @@
/**
* Shared navigation configuration used by Header and Footer to eliminate
* triple duplication of nav link definitions.
*/
/** Lucide icon name used as a key to look up the component at render time. */
export type NavIconName = "wrench" | "plug" | "ghost";
export interface NavItem {
/** Visible link text */
label: string;
/** Route path passed to `<Link to>` */
to: string;
/** Optional search params object passed to `<Link search>` */
search?: Record<string, unknown>;
/** Optional lucide icon name shown beside the label in navbar tabs */
icon?: NavIconName;
/** Link only shown when user is authenticated */
authRequired: boolean;
/** Link only shown for staff / moderator users */
staffOnly: boolean;
/** Link only shown when siteMode === "souls" */
soulModeOnly: boolean;
/** Link hidden when siteMode === "souls" */
soulModeHide: boolean;
/** Additional path prefixes that should also highlight this nav item (e.g. /skill for /skills) */
activePathPrefixes?: string[];
}
// ---------------------------------------------------------------------------
// Search-param shapes (kept here so Header, Footer, and mobile menu all agree)
// ---------------------------------------------------------------------------
const SKILLS_SEARCH = {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
} as const;
const SOULS_SEARCH = {
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
} as const;
const USERS_SEARCH = { q: undefined } as const;
const MANAGEMENT_SEARCH = { skill: undefined } as const;
// ---------------------------------------------------------------------------
// Primary nav items (desktop tabs row + mobile dropdown top section)
// These map to the "content-type" tabs: Skills | Plugins | Souls
// In soul-mode the order is: ClawHub (external), Souls
// In skills-mode: Skills, Plugins, Souls
// ---------------------------------------------------------------------------
export const PRIMARY_NAV_ITEMS: NavItem[] = [
{
label: "Skills",
to: "/skills",
search: SKILLS_SEARCH,
icon: "wrench",
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
activePathPrefixes: ["/skill/"],
},
{
label: "Plugins",
to: "/plugins",
icon: "plug",
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
activePathPrefixes: ["/plugin/"],
},
{
label: "Souls",
to: "/souls",
search: SOULS_SEARCH,
icon: "ghost",
authRequired: false,
staffOnly: false,
soulModeOnly: false,
// In soul-mode this is the primary tab; in skills-mode it is also shown.
soulModeHide: false,
activePathPrefixes: ["/soul/"],
},
];
// ---------------------------------------------------------------------------
// Secondary nav items (secondary tabs row + mobile dropdown lower section)
// ---------------------------------------------------------------------------
export const SECONDARY_NAV_ITEMS: NavItem[] = [
{
label: "Users",
to: "/users",
search: USERS_SEARCH,
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
},
{
label: "About",
to: "/about",
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
},
{
label: "Stars",
to: "/stars",
authRequired: true,
staffOnly: false,
soulModeOnly: false,
soulModeHide: false,
},
{
label: "Dashboard",
to: "/dashboard",
authRequired: true,
staffOnly: false,
soulModeOnly: false,
soulModeHide: false,
},
{
label: "Management",
to: "/management",
search: MANAGEMENT_SEARCH,
authRequired: true,
staffOnly: true,
soulModeOnly: false,
soulModeHide: false,
},
];
// ---------------------------------------------------------------------------
// Footer sections
// ---------------------------------------------------------------------------
export interface FooterNavSection {
title: string;
items: FooterNavItem[];
}
export type FooterNavItem =
| { kind: "link"; label: string; to: string; search?: Record<string, unknown> }
| { kind: "external"; label: string; href: string }
| { kind: "text"; label: string };
export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
{
title: "Browse",
items: [
{ kind: "link", label: "Skills", to: "/skills", search: SKILLS_SEARCH },
{ kind: "link", label: "Plugins", to: "/plugins" },
{ kind: "link", label: "Souls", to: "/souls", search: SOULS_SEARCH },
],
},
{
title: "Publish",
items: [
{
kind: "link",
label: "Publish Skill",
to: "/publish-skill",
search: { updateSlug: undefined },
},
{
kind: "link",
label: "Publish Plugin",
to: "/publish-plugin",
search: {
ownerHandle: undefined,
name: undefined,
displayName: undefined,
family: undefined,
nextVersion: undefined,
sourceRepo: undefined,
},
},
{
kind: "external",
label: "Documentation",
href: "https://github.com/openclaw/clawhub",
},
],
},
{
title: "Community",
items: [
{ kind: "external", label: "GitHub", href: "https://github.com/openclaw/clawhub" },
{ kind: "link", label: "About", to: "/about" },
{ kind: "external", label: "OpenClaw", href: "https://openclaw.ai" },
],
},
{
title: "Platform",
items: [
{ kind: "text", label: "MIT Licensed" },
{ kind: "external", label: "Deployed on Vercel", href: "https://vercel.com" },
{ kind: "external", label: "Powered by Convex", href: "https://www.convex.dev" },
],
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Filter a nav item array based on current mode/auth/staff context. */
export function filterNavItems(
items: NavItem[],
ctx: { isSoulMode: boolean; isAuthenticated: boolean; isStaff: boolean },
): NavItem[] {
return items.filter((item) => {
if (item.soulModeOnly && !ctx.isSoulMode) return false;
if (item.soulModeHide && ctx.isSoulMode) return false;
if (item.authRequired && !ctx.isAuthenticated) return false;
if (item.staffOnly && !ctx.isStaff) return false;
return true;
});
}
+60 -79
View File
@@ -277,51 +277,32 @@ export async function fetchPluginCatalog(params: {
executesCode?: boolean;
limit?: number;
}): Promise<PluginCatalogResult> {
try {
if (params.family) {
const response = await fetchPackages({
q: params.q,
cursor: params.cursor,
family: params.family,
isOfficial: params.isOfficial,
executesCode: params.executesCode,
limit: params.limit,
});
if (hasOwnProperty(response, "results") && Array.isArray(response.results)) {
return {
items: response.results.map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
nextCursor: null,
};
}
const browseResponse = response as PackageCatalogBrowseResponse;
if (params.family) {
const response = await fetchPackages({
q: params.q,
cursor: params.cursor,
family: params.family,
isOfficial: params.isOfficial,
executesCode: params.executesCode,
limit: params.limit,
});
if (hasOwnProperty(response, "results") && Array.isArray(response.results)) {
return {
items: browseResponse?.items ?? [],
nextCursor: browseResponse?.nextCursor ?? null,
};
}
if (params.q?.trim()) {
const url = await packageApiUrl(`${ApiRoutes.plugins}/search`);
url.searchParams.set("q", params.q.trim());
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
}
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
const response = await fetchJson<{
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),
nextCursor: null,
};
}
const url = await packageApiUrl(ApiRoutes.plugins);
if (params.cursor) url.searchParams.set("cursor", params.cursor);
const browseResponse = response as PackageCatalogBrowseResponse;
return {
items: browseResponse.items,
nextCursor: browseResponse.nextCursor,
};
}
if (params.q?.trim()) {
const url = await packageApiUrl(`${ApiRoutes.plugins}/search`);
url.searchParams.set("q", params.q.trim());
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
@@ -329,53 +310,53 @@ export async function fetchPluginCatalog(params: {
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
const result = await fetchJson<PluginCatalogResult>(url);
const response = await fetchJson<{
results: Array<{ score: number; package: PackageListItem }>;
}>(url);
return {
items: result?.items ?? [],
nextCursor: result?.nextCursor ?? null,
items: response.results.map((entry) => entry.package),
nextCursor: null,
};
} catch {
// Return empty result on API error to prevent SSR crashes
return { items: [], nextCursor: null };
}
const url = await packageApiUrl(ApiRoutes.plugins);
if (params.cursor) url.searchParams.set("cursor", params.cursor);
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
}
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
return await fetchJson<PluginCatalogResult>(url);
}
export async function fetchPackageDetail(name: string): Promise<PackageDetailResponse> {
try {
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}`);
const response = await packageFetch(url, "application/json");
if (response.status === 404 || !response.ok) {
return { package: null, owner: null };
}
return (await response.json()) as PackageDetailResponse;
} catch {
// Return empty result on API error to prevent SSR crashes
return { package: null, owner: null };
export async function fetchPackageDetail(name: string) {
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}`);
const response = await packageFetch(url, "application/json");
if (response.status === 404) {
return {
package: null,
owner: null,
} satisfies PackageDetailResponse;
}
if (!response.ok) throw await createPackageApiError(response);
return (await response.json()) as PackageDetailResponse;
}
export async function fetchPackageVersion(name: string, version: string): Promise<PackageVersionDetail | null> {
try {
const url = await packageApiUrl(
`${ApiRoutes.packages}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`,
);
return await fetchJson<PackageVersionDetail>(url);
} catch {
// Return null on API error to prevent SSR crashes
return null;
}
export async function fetchPackageVersion(name: string, version: string) {
const url = await packageApiUrl(
`${ApiRoutes.packages}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`,
);
return await fetchJson<PackageVersionDetail>(url);
}
export async function fetchPackageReadme(name: string, version?: string | null): Promise<string | null> {
try {
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
url.searchParams.set("path", "README.md");
if (version) url.searchParams.set("version", version);
const response = await packageFetch(url, "text/plain");
if (response.ok) return await response.text();
return null;
} catch {
// Return null on API error to prevent SSR crashes
return null;
}
export async function fetchPackageReadme(name: string, version?: string | null) {
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
url.searchParams.set("path", "README.md");
if (version) url.searchParams.set("version", version);
const response = await packageFetch(url, "text/plain");
if (response.ok) return await response.text();
if (response.status === 403 || response.status === 423 || response.status === 404) return null;
throw await createPackageApiError(response);
}
-165
View File
@@ -1,165 +0,0 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
const PREFERENCES_KEY = "clawhub-preferences";
export type LayoutDensity = "comfortable" | "compact";
export type ListViewMode = "grid" | "list";
export type SidebarPosition = "left" | "right";
export type CodeFontSize = "small" | "medium" | "large";
export type AnimationLevel = "full" | "reduced" | "none";
export interface UserPreferences {
// Display preferences
layoutDensity: LayoutDensity;
listViewMode: ListViewMode;
showDescriptions: boolean;
showStats: boolean;
showTags: boolean;
// Advanced layout options
advancedMode: boolean;
sidebarPosition: SidebarPosition;
stickyHeader: boolean;
// Code & content preferences
codeFontSize: CodeFontSize;
lineNumbers: boolean;
wordWrap: boolean;
// Accessibility & motion
animationLevel: AnimationLevel;
reducedMotion: boolean;
highContrast: boolean;
// Notification preferences
emailNotifications: boolean;
browserNotifications: boolean;
// Experimental features
experimentalFeatures: boolean;
}
const defaultPreferences: UserPreferences = {
layoutDensity: "comfortable",
listViewMode: "grid",
showDescriptions: true,
showStats: true,
showTags: true,
advancedMode: false,
sidebarPosition: "right",
stickyHeader: true,
codeFontSize: "medium",
lineNumbers: true,
wordWrap: true,
animationLevel: "full",
reducedMotion: false,
highContrast: false,
emailNotifications: true,
browserNotifications: false,
experimentalFeatures: false,
};
// Simple event emitter for cross-tab sync
const listeners = new Set<() => void>();
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
function notifyListeners() {
listeners.forEach((listener) => listener());
}
function getStoredPreferences(): UserPreferences {
if (typeof window === "undefined") return defaultPreferences;
try {
const stored = window.localStorage.getItem(PREFERENCES_KEY);
if (!stored) return defaultPreferences;
const parsed = JSON.parse(stored) as Partial<UserPreferences>;
return { ...defaultPreferences, ...parsed };
} catch {
return defaultPreferences;
}
}
function savePreferences(prefs: UserPreferences) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(PREFERENCES_KEY, JSON.stringify(prefs));
notifyListeners();
} catch {
// Storage might be full or disabled
}
}
// Server snapshot for SSR
function getServerSnapshot(): UserPreferences {
return defaultPreferences;
}
export function usePreferences() {
const preferences = useSyncExternalStore(
subscribe,
getStoredPreferences,
getServerSnapshot
);
const updatePreference = useCallback(<K extends keyof UserPreferences>(
key: K,
value: UserPreferences[K]
) => {
const current = getStoredPreferences();
const updated = { ...current, [key]: value };
savePreferences(updated);
}, []);
const updatePreferences = useCallback((updates: Partial<UserPreferences>) => {
const current = getStoredPreferences();
const updated = { ...current, ...updates };
savePreferences(updated);
}, []);
const resetPreferences = useCallback(() => {
savePreferences(defaultPreferences);
}, []);
// Apply preferences as CSS variables/classes
useEffect(() => {
if (typeof document === "undefined") return;
const root = document.documentElement;
// Layout density
root.dataset.density = preferences.layoutDensity;
// Animation level
root.dataset.animation = preferences.animationLevel;
// High contrast mode
root.classList.toggle("high-contrast", preferences.highContrast);
// Reduced motion
root.classList.toggle("reduce-motion", preferences.reducedMotion || preferences.animationLevel === "none");
// Code font size
root.style.setProperty("--code-font-size",
preferences.codeFontSize === "small" ? "12px" :
preferences.codeFontSize === "large" ? "16px" : "14px"
);
}, [preferences]);
return {
preferences,
updatePreference,
updatePreferences,
resetPreferences,
isAdvancedMode: preferences.advancedMode,
};
}
export { defaultPreferences };
+31 -43
View File
@@ -1,22 +1,18 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { applyTheme, getStoredTheme, getStoredThemeName, getStoredThemeSelection, useThemeMode } from "./theme";
import { applyTheme, getStoredTheme, useThemeMode } from "./theme";
describe("theme", () => {
let store: Record<string, string>;
function Harness() {
const { family, mode, setFamily, setMode } = useThemeMode();
const { mode, setMode } = useThemeMode();
return (
<div>
<div data-testid="mode">{mode}</div>
<div data-testid="family">{family}</div>
<button type="button" onClick={() => setMode("dark")}>
dark
</button>
<button type="button" onClick={() => setFamily("hub")}>
hub
</button>
</div>
);
}
@@ -43,43 +39,27 @@ describe("theme", () => {
afterEach(() => {
document.documentElement.classList.remove("dark");
delete document.documentElement.dataset.theme;
delete document.documentElement.dataset.themeResolved;
delete document.documentElement.dataset.themeFamily;
delete document.documentElement.dataset.themeMode;
window.localStorage.clear();
vi.unstubAllGlobals();
});
it("reads stored selection with legacy fallback", () => {
it("reads stored theme with fallback", () => {
expect(getStoredTheme()).toBe("system");
expect(getStoredThemeName()).toBe("claw");
window.localStorage.setItem(
"clawhub-theme-selection",
JSON.stringify({ theme: "hub", mode: "light" }),
);
expect(getStoredThemeSelection()).toEqual({ theme: "hub", mode: "light" });
window.localStorage.clear();
window.localStorage.setItem("clawhub-theme", "dark");
expect(getStoredTheme()).toBe("dark");
window.localStorage.clear();
window.localStorage.setItem("clawdhub-theme", "openknot");
expect(getStoredThemeSelection()).toEqual({ theme: "claw", mode: "dark" });
window.localStorage.setItem("clawhub-theme", "nope");
expect(getStoredTheme()).toBe("system");
window.localStorage.setItem("clawdhub-theme", "dark");
expect(getStoredTheme()).toBe("dark");
});
it("applies family and resolved mode to the document", () => {
applyTheme("dark", "hub");
it("applies theme and toggles dark class", () => {
applyTheme("dark");
expect(document.documentElement.dataset.theme).toBe("dark");
expect(document.documentElement.dataset.themeResolved).toBe("dark");
expect(document.documentElement.dataset.themeFamily).toBe("hub");
expect(document.documentElement.classList.contains("dark")).toBe(true);
applyTheme("light", "claw");
applyTheme("light");
expect(document.documentElement.dataset.theme).toBe("light");
expect(document.documentElement.dataset.themeResolved).toBe("light");
expect(document.documentElement.dataset.themeFamily).toBe("claw");
expect(document.documentElement.classList.contains("dark")).toBe(false);
});
@@ -89,11 +69,27 @@ describe("theme", () => {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));
applyTheme("system", "claw");
expect(document.documentElement.dataset.themeResolved).toBe("dark");
applyTheme("system");
expect(document.documentElement.dataset.theme).toBe("dark");
});
it("useThemeMode persists family and mode", async () => {
it("useThemeMode persists and applies mode", async () => {
vi.stubGlobal("matchMedia", () => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));
render(<Harness />);
expect(screen.getByTestId("mode").textContent).toBe("system");
fireEvent.click(screen.getByRole("button", { name: "dark" }));
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe("dark");
});
expect(window.localStorage.getItem("clawhub-theme")).toBe("dark");
});
it("loads stored theme after mount without a mismatched initial render", async () => {
window.localStorage.setItem("clawhub-theme", "dark");
vi.stubGlobal("matchMedia", () => ({
matches: false,
addEventListener: vi.fn(),
@@ -101,18 +97,10 @@ describe("theme", () => {
}));
render(<Harness />);
expect(screen.getByTestId("mode").textContent).toBe("system");
expect(screen.getByTestId("family").textContent).toBe("claw");
fireEvent.click(screen.getByRole("button", { name: "hub" }));
fireEvent.click(screen.getByRole("button", { name: "dark" }));
await waitFor(() => {
expect(document.documentElement.dataset.themeFamily).toBe("hub");
expect(document.documentElement.dataset.themeResolved).toBe("dark");
expect(screen.getByTestId("mode").textContent).toBe("dark");
});
expect(window.localStorage.getItem("clawhub-theme")).toBe("dark");
expect(window.localStorage.getItem("clawhub-theme-name")).toBe("hub");
expect(document.documentElement.dataset.theme).toBe("dark");
});
});
+30 -205
View File
@@ -1,228 +1,53 @@
import { useEffect, useState } from 'react';
import {
clearStoredCustomTheme,
getStoredCustomTheme,
parseThemeInput,
setStoredCustomTheme,
syncCustomThemeFromStorage,
type CustomThemeData,
} from './customTheme';
import { useEffect, useState } from "react";
export type ThemeName = 'claw' | 'hub';
export type ThemeMode = 'system' | 'light' | 'dark';
export type ResolvedTheme = 'light' | 'dark';
export type ThemeMode = "system" | "light" | "dark";
export type ThemeSelection = {
theme: ThemeName;
mode: ThemeMode;
};
const THEME_SELECTION_KEY = 'clawhub-theme-selection';
const THEME_KEY = 'clawhub-theme';
const LEGACY_THEME_KEY = 'clawdhub-theme';
const THEME_NAME_KEY = 'clawhub-theme-name';
const THEME_CHANGE_EVENT = 'clawhub:themechange';
export const THEME_OPTIONS: Array<{ value: ThemeName; label: string; description: string }> = [
{
value: 'claw',
label: 'Claw',
description: 'OpenClaw black, white, and red.',
},
{
value: 'hub',
label: 'Hub',
description: 'Marketplace monochrome index with terminal-style contrast.',
},
];
export const THEME_FAMILY_OPTIONS = THEME_OPTIONS;
const VALID_THEME_NAMES = new Set<ThemeName>(['claw', 'hub']);
const VALID_THEME_MODES = new Set<ThemeMode>(['system', 'light', 'dark']);
const LEGACY_MAP: Record<string, ThemeSelection> = {
dark: { theme: 'claw', mode: 'dark' },
light: { theme: 'claw', mode: 'light' },
system: { theme: 'claw', mode: 'system' },
defaultTheme: { theme: 'claw', mode: 'dark' },
docsTheme: { theme: 'claw', mode: 'light' },
lightTheme: { theme: 'claw', mode: 'dark' },
landingTheme: { theme: 'claw', mode: 'dark' },
newTheme: { theme: 'claw', mode: 'dark' },
openknot: { theme: 'claw', mode: 'dark' },
fieldmanual: { theme: 'hub', mode: 'dark' },
clawdash: { theme: 'hub', mode: 'light' },
};
function parseThemeSelection(themeRaw: unknown, modeRaw: unknown): ThemeSelection {
const theme = typeof themeRaw === 'string' ? themeRaw : '';
const mode = typeof modeRaw === 'string' ? modeRaw : '';
const normalizedTheme = VALID_THEME_NAMES.has(theme as ThemeName)
? (theme as ThemeName)
: (LEGACY_MAP[theme]?.theme ?? 'claw');
const normalizedMode = VALID_THEME_MODES.has(mode as ThemeMode)
? (mode as ThemeMode)
: (LEGACY_MAP[theme]?.mode ?? 'system');
return { theme: normalizedTheme, mode: normalizedMode };
}
function persistThemeSelection(selection: ThemeSelection) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(THEME_SELECTION_KEY, JSON.stringify(selection));
window.localStorage.setItem(THEME_KEY, selection.mode);
window.localStorage.setItem(THEME_NAME_KEY, selection.theme);
}
export function getStoredThemeSelection(): ThemeSelection {
if (typeof window === 'undefined') return { theme: 'claw', mode: 'system' };
try {
const storedSelection = window.localStorage.getItem(THEME_SELECTION_KEY);
if (storedSelection) {
const parsed = JSON.parse(storedSelection) as Partial<ThemeSelection>;
return parseThemeSelection(parsed.theme, parsed.mode);
}
} catch {
// fall through to legacy keys
}
const storedMode = window.localStorage.getItem(THEME_KEY);
const storedTheme = window.localStorage.getItem(THEME_NAME_KEY);
if (storedMode || storedTheme) {
return parseThemeSelection(storedTheme, storedMode);
}
const legacy = window.localStorage.getItem(LEGACY_THEME_KEY);
if (legacy) {
return parseThemeSelection(legacy, undefined);
}
return { theme: 'claw', mode: 'system' };
}
const THEME_KEY = "clawhub-theme";
const LEGACY_THEME_KEY = "clawdhub-theme";
export function getStoredTheme(): ThemeMode {
return getStoredThemeSelection().mode;
if (typeof window === "undefined") return "system";
const stored = window.localStorage.getItem(THEME_KEY);
if (stored === "light" || stored === "dark" || stored === "system") return stored;
const legacy = window.localStorage.getItem(LEGACY_THEME_KEY);
if (legacy === "light" || legacy === "dark" || legacy === "system") return legacy;
return "system";
}
export function getStoredThemeName(): ThemeName {
return getStoredThemeSelection().theme;
function resolveTheme(mode: ThemeMode) {
if (mode !== "system") return mode;
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function getThemeFamilyLabel(theme: ThemeName): string {
return THEME_OPTIONS.find((option) => option.value === theme)?.label ?? 'Claw';
}
function resolveMode(mode: ThemeMode): ResolvedTheme {
if (mode !== 'system') return mode;
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function resolveTheme(theme: ThemeName, mode: ThemeMode): ResolvedTheme {
void theme;
return resolveMode(mode);
}
export function isDarkResolvedTheme(resolvedTheme: string | null | undefined): boolean {
return resolvedTheme === 'dark';
}
export function isDarkThemeResolved(): boolean {
if (typeof document === 'undefined') return false;
return isDarkResolvedTheme(document.documentElement.dataset.themeResolved);
}
export function applyTheme(selectionOrMode: ThemeSelection | ThemeMode, theme: ThemeName = 'claw') {
const selection = typeof selectionOrMode === 'string' ? { theme, mode: selectionOrMode } : selectionOrMode;
applyThemeSelection(selection);
}
export function applyThemeSelection(selection: ThemeSelection) {
if (typeof document === 'undefined') return;
const resolved = resolveTheme(selection.theme, selection.mode);
export function applyTheme(mode: ThemeMode) {
if (typeof document === "undefined") return;
const resolved = resolveTheme(mode);
document.documentElement.dataset.theme = resolved;
document.documentElement.dataset.themeResolved = resolved;
document.documentElement.dataset.themeMode = selection.mode;
document.documentElement.dataset.themeFamily = selection.theme;
document.documentElement.classList.toggle('dark', isDarkResolvedTheme(resolved));
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT));
}
}
export function onThemeChange(handler: () => void) {
if (typeof window === 'undefined') return () => {};
window.addEventListener(THEME_CHANGE_EVENT, handler);
return () => window.removeEventListener(THEME_CHANGE_EVENT, handler);
document.documentElement.classList.toggle("dark", resolved === "dark");
}
export function useThemeMode() {
const [selection, setSelection] = useState<ThemeSelection>({ theme: 'claw', mode: 'system' });
const [mode, setMode] = useState<ThemeMode>("system");
const [isHydrated, setIsHydrated] = useState(false);
const [customTheme, setCustomTheme] = useState<CustomThemeData | null>(null);
useEffect(() => {
setSelection(getStoredThemeSelection());
setCustomTheme(getStoredCustomTheme());
setMode(getStoredTheme());
setIsHydrated(true);
}, []);
useEffect(() => {
if (!isHydrated) return;
applyThemeSelection(selection);
persistThemeSelection(selection);
syncCustomThemeFromStorage();
if (selection.mode !== 'system' || typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return;
applyTheme(mode);
if (typeof window !== "undefined") {
window.localStorage.setItem(THEME_KEY, mode);
}
if (mode !== "system" || typeof window === "undefined") return;
const media = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => applyTheme(mode);
media.addEventListener("change", handler);
return () => media.removeEventListener("change", handler);
}, [isHydrated, mode]);
const media = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => {
applyThemeSelection(selection);
syncCustomThemeFromStorage();
};
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', handler);
return () => media.removeEventListener('change', handler);
}
media.addListener(handler);
return () => media.removeListener(handler);
}, [isHydrated, selection]);
const importCustomTheme = async (input: string) => {
const parsed = await parseThemeInput(input);
const theme = {
...parsed,
source: input.trim(),
};
setStoredCustomTheme(theme);
setCustomTheme(theme);
syncCustomThemeFromStorage();
return theme;
};
const clearCustomTheme = () => {
clearStoredCustomTheme();
setCustomTheme(null);
syncCustomThemeFromStorage();
};
return {
theme: selection.theme,
family: selection.theme,
mode: selection.mode,
selection,
customTheme,
setTheme: (theme: ThemeName) => setSelection((current) => ({ ...current, theme })),
setFamily: (theme: ThemeName) => setSelection((current) => ({ ...current, theme })),
setMode: (mode: ThemeMode) => setSelection((current) => ({ ...current, mode })),
importCustomTheme,
clearCustomTheme,
};
return { mode, setMode };
}
-33
View File
@@ -1,33 +0,0 @@
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const WEEK = 7 * DAY;
const MONTH = 30 * DAY;
const YEAR = 365 * DAY;
export function timeAgo(timestamp: number): string {
const diff = Date.now() - timestamp;
if (diff < MINUTE) return "just now";
if (diff < HOUR) {
const m = Math.floor(diff / MINUTE);
return `${m}m ago`;
}
if (diff < DAY) {
const h = Math.floor(diff / HOUR);
return `${h}h ago`;
}
if (diff < WEEK) {
const d = Math.floor(diff / DAY);
return `${d}d ago`;
}
if (diff < MONTH) {
const w = Math.floor(diff / WEEK);
return `${w}w ago`;
}
if (diff < YEAR) {
const m = Math.floor(diff / MONTH);
return `${m}mo ago`;
}
const y = Math.floor(diff / YEAR);
return `${y}y ago`;
}
+10 -15
View File
@@ -1,38 +1,33 @@
import { useSyncExternalStore } from "react";
// Tiny external store for auth errors raised during sign-in.
// Syncs across components in this tab.
let authError: string | null = null;
const listeners = new Set<() => void>();
function emitChange() {
for (const listener of listeners) listener();
for (const listener of listeners) listener();
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
listeners.add(listener);
return () => listeners.delete(listener);
}
export function getAuthErrorSnapshot() {
return authError;
return authError;
}
export function setAuthError(error: string | null) {
if (authError === error) return;
authError = error;
emitChange();
if (authError === error) return;
authError = error;
emitChange();
}
export function clearAuthError() {
setAuthError(null);
setAuthError(null);
}
export function useAuthError() {
const error = useSyncExternalStore(
subscribe,
getAuthErrorSnapshot,
getAuthErrorSnapshot,
);
return { error, clear: clearAuthError };
const error = useSyncExternalStore(subscribe, getAuthErrorSnapshot, getAuthErrorSnapshot);
return { error, clear: clearAuthError };
}
-158
View File
@@ -1,158 +0,0 @@
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 UnifiedSkillResult = {
type: "skill";
skill: {
_id: string;
slug: string;
displayName: string;
summary?: string | null;
ownerUserId: string;
ownerPublisherId?: string | null;
stats: { downloads: number; stars: number; versions?: number };
updatedAt: number;
createdAt: number;
};
ownerHandle: string | null;
score: number;
};
export type UnifiedPluginResult = {
type: "plugin";
plugin: PackageListItem;
};
export type UnifiedUserResult = {
type: "user";
user: PublicUser;
};
export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult | UnifiedUserResult;
export function useUnifiedSearch(
query: string,
activeType: "all" | "skills" | "plugins" | "users",
) {
const searchSkills = useAction(api.search.searchSkills);
const [results, setResults] = useState<UnifiedResult[]>([]);
const [skillCount, setSkillCount] = useState(0);
const [pluginCount, setPluginCount] = useState(0);
const [userCount, setUserCount] = useState(0);
const [isSearching, setIsSearching] = useState(false);
const requestRef = useRef(0);
useEffect(() => {
const trimmed = query.trim();
if (!trimmed) {
requestRef.current += 1;
setResults([]);
setSkillCount(0);
setPluginCount(0);
setUserCount(0);
setIsSearching(false);
return;
}
requestRef.current += 1;
const requestId = requestRef.current;
setIsSearching(true);
const handle = window.setTimeout(() => {
void (async () => {
try {
const promises: [
Promise<unknown> | null,
Promise<{ items: PackageListItem[] }> | null,
Promise<{ items: PublicUser[] }> | null,
] = [null, null, null];
if (activeType === "all" || activeType === "skills") {
promises[0] = searchSkills({
query: trimmed,
limit: 25,
nonSuspiciousOnly: true,
});
}
if (activeType === "all" || activeType === "plugins") {
promises[1] = fetchPluginCatalog({ q: trimmed, limit: 25 });
}
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)),
);
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 }>) ?? []
).map((entry) => ({
type: "skill" as const,
skill: entry.skill,
ownerHandle: entry.ownerHandle,
score: entry.score,
}));
const pluginResults: 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);
const merged: UnifiedResult[] = [];
if (activeType === "all") {
merged.push(...skillResults, ...pluginResults, ...userResults);
} else if (activeType === "skills") {
merged.push(...skillResults);
} else if (activeType === "plugins") {
merged.push(...pluginResults);
} else {
merged.push(...userResults);
}
setResults(merged);
} catch (error) {
console.error("Unified search failed:", error);
if (requestId === requestRef.current) {
setResults([]);
setSkillCount(0);
setPluginCount(0);
setUserCount(0);
}
} finally {
if (requestId === requestRef.current) {
setIsSearching(false);
}
}
})();
}, 300);
return () => window.clearTimeout(handle);
}, [query, activeType, searchSkills]);
return { results, skillCount, pluginCount, userCount, isSearching };
}
-21
View File
@@ -21,7 +21,6 @@ import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as AdminRouteImport } from './routes/admin'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
import { Route as UsersIndexRouteImport } from './routes/users/index'
import { Route as SoulsIndexRouteImport } from './routes/souls/index'
import { Route as SkillsIndexRouteImport } from './routes/skills/index'
import { Route as PluginsIndexRouteImport } from './routes/plugins/index'
@@ -96,11 +95,6 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const UsersIndexRoute = UsersIndexRouteImport.update({
id: '/users/',
path: '/users/',
getParentRoute: () => rootRouteImport,
} as any)
const SoulsIndexRoute = SoulsIndexRouteImport.update({
id: '/souls/',
path: '/souls/',
@@ -193,7 +187,6 @@ export interface FileRoutesByFullPath {
'/plugins/': typeof PluginsIndexRoute
'/skills/': typeof SkillsIndexRoute
'/souls/': typeof SoulsIndexRoute
'/users/': typeof UsersIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -221,7 +214,6 @@ export interface FileRoutesByTo {
'/plugins': typeof PluginsIndexRoute
'/skills': typeof SkillsIndexRoute
'/souls': typeof SoulsIndexRoute
'/users': typeof UsersIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -250,7 +242,6 @@ export interface FileRoutesById {
'/plugins/': typeof PluginsIndexRoute
'/skills/': typeof SkillsIndexRoute
'/souls/': typeof SoulsIndexRoute
'/users/': typeof UsersIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -280,7 +271,6 @@ export interface FileRouteTypes {
| '/plugins/'
| '/skills/'
| '/souls/'
| '/users/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -308,7 +298,6 @@ export interface FileRouteTypes {
| '/plugins'
| '/skills'
| '/souls'
| '/users'
id:
| '__root__'
| '/'
@@ -336,7 +325,6 @@ export interface FileRouteTypes {
| '/plugins/'
| '/skills/'
| '/souls/'
| '/users/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -365,7 +353,6 @@ export interface RootRouteChildren {
PluginsIndexRoute: typeof PluginsIndexRoute
SkillsIndexRoute: typeof SkillsIndexRoute
SoulsIndexRoute: typeof SoulsIndexRoute
UsersIndexRoute: typeof UsersIndexRoute
}
declare module '@tanstack/react-router' {
@@ -454,13 +441,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/users/': {
id: '/users/'
path: '/users'
fullPath: '/users/'
preLoaderRoute: typeof UsersIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/souls/': {
id: '/souls/'
path: '/souls'
@@ -581,7 +561,6 @@ const rootRouteChildren: RootRouteChildren = {
PluginsIndexRoute: PluginsIndexRoute,
SkillsIndexRoute: SkillsIndexRoute,
SoulsIndexRoute: SoulsIndexRoute,
UsersIndexRoute: UsersIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+1 -8
View File
@@ -1,16 +1,9 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { SkillDetailPage } from "../../components/SkillDetailPage";
import { buildSkillMeta } from "../../lib/og";
import { fetchSkillPageData } from "../../lib/skillPage";
export const Route = createFileRoute("/$owner/$slug")({
beforeLoad: ({ params }) => {
const isHandle = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(params.owner);
const isOwnerId = params.owner.startsWith("users:") || params.owner.startsWith("publishers:");
if (!isHandle && !isOwnerId) {
throw notFound();
}
},
loader: async ({ params }) => {
const data = await fetchSkillPageData(params.slug);
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
-18
View File
@@ -92,24 +92,6 @@ export const Route = createRootRoute({
rel: "stylesheet",
href: appCss,
},
{
rel: "icon",
href: "/favicon.ico",
type: "image/x-icon",
},
{
rel: "icon",
href: "/logo.svg",
type: "image/svg+xml",
},
{
rel: "apple-touch-icon",
href: "/logo192.png",
},
{
rel: "manifest",
href: "/manifest.json",
},
],
};
},
+151 -126
View File
@@ -1,65 +1,69 @@
import { createFileRoute, Link } from '@tanstack/react-router';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { getSiteMode, getSiteName, getSiteUrlForMode } from '../lib/site';
import { createFileRoute, Link } from "@tanstack/react-router";
import { AlertTriangle, Shield, ShieldAlert } from "lucide-react";
import { Container } from "../components/layout/Container";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Separator } from "../components/ui/separator";
import { getSiteMode, getSiteName, getSiteUrlForMode } from "../lib/site";
const prohibitedCategories = [
{
title: 'Bypass and unauthorized access',
title: "Bypass and unauthorized access",
examples:
'Auth bypass, account takeover, CAPTCHA bypass, Cloudflare or anti-bot evasion, rate-limit bypass, reusable session theft, live call or agent takeover.',
"Auth bypass, account takeover, CAPTCHA bypass, Cloudflare or anti-bot evasion, rate-limit bypass, reusable session theft, live call or agent takeover.",
},
{
title: 'Platform abuse and ban evasion',
title: "Platform abuse and ban evasion",
examples:
'Stealth accounts after bans, account warming/farming, fake engagement, multi-account automation, spam posting, marketplace or social automation built to avoid detection.',
"Stealth accounts after bans, account warming/farming, fake engagement, multi-account automation, spam posting, marketplace or social automation built to avoid detection.",
},
{
title: 'Fraud and deception',
title: "Fraud and deception",
examples:
'Fake certificates, fake invoices, deceptive payment flows, fake social proof, scam outreach, or synthetic-identity workflows built to create accounts for fraud.',
"Fake certificates, fake invoices, deceptive payment flows, fake social proof, scam outreach, or synthetic-identity workflows built to create accounts for fraud.",
},
{
title: 'Privacy-invasive surveillance',
title: "Privacy-invasive surveillance",
examples:
'Mass contact scraping for spam, doxxing, stalking, covert monitoring, biometric / face-matching workflows without clear consent, or buying, publishing, downloading, or operationalizing leaked data or breach dumps.',
"Mass contact scraping for spam, doxxing, stalking, covert monitoring, biometric / face-matching workflows without clear consent, or buying, publishing, downloading, or operationalizing leaked data or breach dumps.",
},
{
title: 'Non-consensual impersonation',
title: "Non-consensual impersonation",
examples:
'Face swap, digital twins, cloned influencers, fake personas, or other identity manipulation used to impersonate or mislead.',
"Face swap, digital twins, cloned influencers, fake personas, or other identity manipulation used to impersonate or mislead.",
},
{
title: 'Explicit sexual content',
title: "Explicit sexual content",
examples:
'NSFW image, video, or text generation, especially wrappers around third-party APIs with safety checks disabled.',
"NSFW image, video, or text generation, especially wrappers around third-party APIs with safety checks disabled.",
},
{
title: 'Hidden or misleading execution',
title: "Hidden or misleading execution",
examples:
'Obfuscated install commands, `curl | sh`, undeclared secret requirements, undeclared private-key use, or remote `npx @latest` execution without reviewability.',
"Obfuscated install commands, `curl | sh`, undeclared secret requirements, undeclared private-key use, or remote `npx @latest` execution without reviewability.",
},
];
const recentPatterns = [
'Create stealth seller accounts after marketplace bans.',
'Modify Telegram pairing so unapproved users automatically receive pairing codes.',
'Cultivate Reddit or Twitter accounts with undetectable automation.',
'Generate professional certificates or invoices for arbitrary use.',
'Generate NSFW content with safety checks disabled.',
'Scrape leads, enrich contacts, and launch cold outreach at scale.',
'Buy, publish, or download leaked data or breach dumps.',
'Bulk-create email or social accounts with synthetic identities or CAPTCHA solving.',
"Create stealth seller accounts after marketplace bans.",
"Modify Telegram pairing so unapproved users automatically receive pairing codes.",
"Cultivate Reddit or Twitter accounts with undetectable automation.",
"Generate professional certificates or invoices for arbitrary use.",
"Generate NSFW content with safety checks disabled.",
"Scrape leads, enrich contacts, and launch cold outreach at scale.",
"Buy, publish, or download leaked data or breach dumps.",
"Bulk-create email or social accounts with synthetic identities or CAPTCHA solving.",
];
export const Route = createFileRoute('/about')({
export const Route = createFileRoute("/about")({
head: () => {
const mode = getSiteMode();
const siteName = getSiteName(mode);
const siteUrl = getSiteUrlForMode(mode);
const title = `About · ${siteName}`;
const description =
'What ClawHub allows, what we do not host, and the abuse patterns that lead to removal or account bans.';
"What ClawHub allows, what we do not host, and the abuse patterns that lead to removal or account bans.";
return {
links: [
@@ -70,11 +74,11 @@ export const Route = createFileRoute('/about')({
],
meta: [
{ title },
{ name: 'description', content: description },
{ property: 'og:title', content: title },
{ property: 'og:description', content: description },
{ property: 'og:type', content: 'website' },
{ property: 'og:url', content: `${siteUrl}/about` },
{ name: "description", content: description },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:type", content: "website" },
{ property: "og:url", content: `${siteUrl}/about` },
],
};
},
@@ -83,105 +87,126 @@ export const Route = createFileRoute('/about')({
function AboutPage() {
return (
<main className="section about-page">
<div className="about-bento">
<section className="about-panel about-panel-hero">
<div className="about-hero-copy">
<div className="skill-card-tags mb-3">
<Badge>About</Badge>
<Badge variant="accent">Policy</Badge>
</div>
<h1 className="about-title">What ClawHub will not host</h1>
<p className="about-lead">
ClawHub is for useful agent tooling, not abuse workflows. If a skill is built to
evade defenses, scam people, invade privacy, or enable non-consensual behavior, it
does not belong here.
</p>
</div>
</section>
<main className="py-10">
<Container size="wide">
<div className="flex flex-col gap-6">
<Card>
<CardContent className="flex flex-col gap-4 pt-6">
<div className="flex flex-wrap gap-2">
<Badge>About</Badge>
<Badge variant="accent">Policy</Badge>
</div>
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)]">
What ClawHub Will Not Host
</h1>
<p className="text-sm leading-relaxed text-[color:var(--ink-soft)]">
ClawHub is for useful agent tooling, not abuse workflows. If a skill is built to
evade defenses, abuse platforms, scam people, invade privacy, or enable
non-consensual behavior, it does not belong here.
</p>
<div className="flex items-center gap-2 rounded-[var(--radius-sm)] bg-[color:var(--surface-muted)] px-4 py-3 text-sm font-medium text-[color:var(--ink-soft)]">
<Shield className="h-4 w-4 shrink-0 text-[color:var(--accent)]" />
We moderate based on end-to-end abuse patterns, not just isolated keywords.
</div>
</CardContent>
</Card>
<aside className="about-panel about-panel-callout">
<div className="about-callout">
<span className="about-callout-label">Moderation stance</span>
<p>
We judge end-to-end abuse patterns, not keyword theater. Useful tooling stays.
Predatory workflows get removed.
</p>
</div>
</aside>
<section className="about-panel about-panel-categories">
<div className="home-section-header">
<h2 className="home-section-title">Immediate rejection categories</h2>
</div>
<div className="about-grid">
{prohibitedCategories.map((category, index) => (
<article
key={category.title}
className={`about-rule-card${index % 3 === 0 ? ' about-rule-card-featured' : ''}`}
>
<h2>{category.title}</h2>
<p>{category.examples}</p>
</article>
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-4">
{prohibitedCategories.map((category) => (
<Card key={category.title}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<ShieldAlert className="h-4 w-4 shrink-0 text-[color:var(--gold)]" />
{category.title}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm leading-relaxed text-[color:var(--ink-soft)]">
{category.examples}
</p>
</CardContent>
</Card>
))}
</div>
</section>
<section className="about-panel about-panel-patterns">
<div className="home-section-header">
<h2 className="home-section-title">Recent patterns we are explicitly not okay with</h2>
</div>
<div className="about-patterns">
{recentPatterns.map((pattern) => (
<div key={pattern} className="about-pattern">
{pattern}
</div>
))}
</div>
</section>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 shrink-0 text-[color:var(--gold)]" />
Recent patterns we are explicitly not okay with
</CardTitle>
</CardHeader>
<CardContent>
<ul className="flex flex-col gap-2">
{recentPatterns.map((pattern) => (
<li
key={pattern}
className="flex items-start gap-2 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] px-4 py-3 text-sm text-[color:var(--ink-soft)]"
>
<span className="mt-0.5 shrink-0 text-[color:var(--gold)]" aria-hidden="true">
-
</span>
{pattern}
</li>
))}
</ul>
</CardContent>
</Card>
<section className="about-panel about-panel-enforcement">
<div>
<span className="about-callout-label">Enforcement</span>
<div className="management-sublist">
<div className="management-subitem">
We may hide, remove, or hard-delete violating skills.
<Card>
<CardHeader>
<CardTitle>Enforcement</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<ul className="flex flex-col gap-2">
<li className="flex items-start gap-2 text-sm text-[color:var(--ink-soft)]">
<span className="mt-0.5 shrink-0 text-[color:var(--accent)]" aria-hidden="true">
1.
</span>
We may hide, remove, or hard-delete violating skills.
</li>
<li className="flex items-start gap-2 text-sm text-[color:var(--ink-soft)]">
<span className="mt-0.5 shrink-0 text-[color:var(--accent)]" aria-hidden="true">
2.
</span>
We may revoke tokens, soft-delete associated content, and ban repeat or severe
offenders.
</li>
<li className="flex items-start gap-2 text-sm text-[color:var(--ink-soft)]">
<span className="mt-0.5 shrink-0 text-[color:var(--accent)]" aria-hidden="true">
3.
</span>
We do not guarantee warning-first enforcement for obvious abuse.
</li>
</ul>
<Separator />
<div className="flex flex-wrap gap-3">
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
>
<Button variant="primary">Browse Skills</Button>
</Link>
<a
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
target="_blank"
rel="noreferrer"
>
<Button variant="outline">Reviewer Doc</Button>
</a>
</div>
<div className="management-subitem">
We may revoke tokens, soft-delete associated content, and ban repeat or severe
offenders.
</div>
<div className="management-subitem">
We do not guarantee warning-first enforcement for obvious abuse.
</div>
</div>
</div>
</section>
<section className="about-panel about-panel-actions">
<span className="about-callout-label">Next steps</span>
<p className="about-panel-copy">
If you are reviewing a borderline workflow, use the reviewer doc. If you are browsing,
stay in the public catalog.
</p>
<div className="skill-card-tags">
<Button asChild variant="primary">
<Link to="/skills">
Browse Skills
</Link>
</Button>
<Button asChild>
<a
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
target="_blank"
rel="noreferrer"
>
Reviewer Doc
</a>
</Button>
</div>
</section>
</div>
</CardContent>
</Card>
</div>
</Container>
</main>
);
}
+162 -165
View File
@@ -16,9 +16,12 @@ import { useEffect, useState } from "react";
import semver from "semver";
import { api } from "../../convex/_generated/api";
import type { Doc } from "../../convex/_generated/dataModel";
import { EmptyState } from "../components/EmptyState";
import { Container } from "../components/layout/Container";
import { SignInButton } from "../components/SignInButton";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { Card, CardContent } from "../components/ui/card";
import { formatCompactStat } from "../lib/numberFormat";
import { familyLabel } from "../lib/packageLabels";
import type { PublicSkill } from "../lib/publicUser";
@@ -107,7 +110,8 @@ function Dashboard() {
useEffect(() => {
if (selectedPublisherId) return;
const personal = publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
const personal =
publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
if (personal?.publisher._id) {
setSelectedPublisherId(personal.publisher._id);
}
@@ -115,72 +119,38 @@ function Dashboard() {
if (!me) {
return (
<main className="section">
<Card>Sign in to access your dashboard.</Card>
</main>
<Container className="py-10">
<Card>
<CardContent className="flex flex-col items-start gap-3">
<span>Sign in to access your dashboard.</span>
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
</CardContent>
</Card>
</Container>
);
}
const skills = mySkills ?? [];
const packages = myPackages ?? [];
const isLoading = mySkills === undefined;
const ownerHandle =
selectedPublisher?.publisher.handle ?? me.handle ?? me.name ?? me.displayName ?? me._id;
// Welcome state for new users with no content
if (!isLoading && skills.length === 0 && packages.length === 0) {
return (
<main className="section">
<div className="empty-state">
<h1 className="empty-state-title text-[1.4rem] font-[family-name:var(--font-display)]">
Welcome to ClawHub
</h1>
<p className="empty-state-body">
You're signed in as @{ownerHandle}. Get started by publishing your first skill or plugin.
</p>
<div className="flex gap-3 justify-center">
<Button asChild variant="primary">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
Publish a Skill
</Link>
</Button>
<Button asChild>
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
>
Browse Skills
</Link>
</Button>
</div>
</div>
</main>
);
}
return (
<main className="section">
<div className="dashboard-header">
<div>
<h1 className="section-title m-0">
<Container className="py-10">
{/* Header */}
<div className="mb-8 flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
<div className="grid gap-1.5">
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)]">
Publisher Dashboard
</h1>
<p className="section-subtitle m-0">
Manage your published skills and plugins.
<p className="text-sm text-[color:var(--ink-soft)]">
Owner-only view for skills and plugins, including security scans and verification.
</p>
</div>
<div className="flex gap-2 flex-wrap">
<div className="flex flex-wrap gap-2">
{publishers && publishers.length > 0 ? (
<select
className="input"
className="min-h-[44px] rounded-[var(--radius-pill)] border border-[color:var(--line)] bg-[color:var(--surface)] px-4 py-[11px] text-sm text-[color:var(--ink)] transition-all duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35"
value={selectedPublisherId}
onChange={(event) => setSelectedPublisherId(event.target.value)}
>
@@ -198,10 +168,7 @@ function Dashboard() {
</Link>
</Button>
<Button asChild>
<Link
to="/publish-plugin"
search={{ ...emptyPluginPublishSearch, ownerHandle }}
>
<Link to="/publish-plugin" search={{ ...emptyPluginPublishSearch, ownerHandle }}>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
@@ -209,101 +176,109 @@ function Dashboard() {
</div>
</div>
<Card className="dashboard-owner-panel">
<div className="dashboard-owner-grid">
<section className="dashboard-collection-block">
<div className="dashboard-section-header">
{/* Owner panel */}
<Card>
<CardContent>
<div className="grid gap-10">
{/* Skills section */}
<section className="flex flex-col gap-4">
<div>
<h2 className="dashboard-collection-title">Publisher Skills</h2>
<p className="section-subtitle mt-1.5 mb-0 mx-0">
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
Publisher Skills
</h2>
<p className="mt-1.5 text-sm text-[color:var(--ink-soft)]">
Hidden skill versions remain visible here while checks are pending.
</p>
</div>
</div>
{skills.length === 0 ? (
<div className="dashboard-inline-empty">
<div className="dashboard-inline-empty-copy">
<strong>No skills yet.</strong> Publish your first skill to share it with the community.
{skills.length === 0 ? (
<EmptyState
icon={Upload}
title="No skills yet."
description="Publish your first skill to share it with the community."
>
<Button asChild variant="primary">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Upload className="h-4 w-4" aria-hidden="true" />
Publish Skill
</Link>
</Button>
</EmptyState>
) : (
<div className="flex flex-col">
<div className="hidden grid-cols-[2fr_2fr_1.5fr_auto] gap-4 border-b border-[color:var(--line)] px-4 pb-2 text-xs font-semibold uppercase tracking-wider text-[color:var(--ink-soft)] md:grid">
<span>Skill</span>
<span>Summary</span>
<span>Status</span>
<span>Actions</span>
</div>
{skills.map((skill) => (
<SkillRow key={skill._id} skill={skill} ownerHandle={ownerHandle} />
))}
</div>
<Button asChild variant="primary">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Upload className="h-4 w-4" aria-hidden="true" />
Publish Skill
</Link>
</Button>
</div>
) : (
<div className="dashboard-list">
<div className="dashboard-list-header">
<span>Skill</span>
<span>Summary</span>
<span>Status</span>
<span>Actions</span>
</div>
{skills.map((skill) => (
<SkillRow key={skill._id} skill={skill} ownerHandle={ownerHandle} />
))}
</div>
)}
</section>
)}
</section>
<section className="dashboard-collection-block">
<div className="dashboard-section-header">
{/* Plugins section */}
<section className="flex flex-col gap-4">
<div>
<h2 className="dashboard-collection-title">Publisher Plugins</h2>
<p className="section-subtitle mt-1.5 mb-0 mx-0">
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
Publisher Plugins
</h2>
<p className="mt-1.5 text-sm text-[color:var(--ink-soft)]">
Owner-only package view with VirusTotal, static scan, and verification state.
</p>
</div>
</div>
{packages.length === 0 ? (
<div className="dashboard-inline-empty">
<div className="dashboard-inline-empty-copy">
<strong>No plugins yet.</strong> Publish your first plugin release to validate and distribute it.
{packages.length === 0 ? (
<EmptyState
icon={Plug}
title="No plugins yet."
description="Publish your first plugin release to validate and distribute it."
>
<Button asChild variant="primary">
<Link
to="/publish-plugin"
search={{ ...emptyPluginPublishSearch, ownerHandle }}
>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
</Button>
</EmptyState>
) : (
<div className="flex flex-col">
<div className="hidden grid-cols-[2fr_2fr_1.5fr_auto] gap-4 border-b border-[color:var(--line)] px-4 pb-2 text-xs font-semibold uppercase tracking-wider text-[color:var(--ink-soft)] md:grid">
<span>Plugin</span>
<span>Summary</span>
<span>Status</span>
<span>Actions</span>
</div>
{packages.map((pkg) => (
<PackageRow key={pkg._id} pkg={pkg} ownerHandle={ownerHandle} />
))}
</div>
<Button asChild variant="primary">
<Link
to="/publish-plugin"
search={{ ...emptyPluginPublishSearch, ownerHandle }}
>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
</Button>
</div>
) : (
<div className="dashboard-list">
<div className="dashboard-list-header">
<span>Plugin</span>
<span>Summary</span>
<span>Status</span>
<span>Actions</span>
</div>
{packages.map((pkg) => (
<PackageRow key={pkg._id} pkg={pkg} ownerHandle={ownerHandle} />
))}
</div>
)}
</section>
</div>
)}
</section>
</div>
</CardContent>
</Card>
</main>
</Container>
);
}
function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle: string | null }) {
return (
<div className="dashboard-list-row">
<div className="dashboard-list-primary">
<div className="dashboard-list-title">
<div className="grid items-start gap-4 border-b border-[color:var(--line)] px-4 py-4 last:border-b-0 md:grid-cols-[2fr_2fr_1.5fr_auto]">
{/* Primary info */}
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<Link
to="/$owner/$slug"
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
className="dashboard-skill-name"
className="font-display text-sm font-bold text-[color:var(--ink)] hover:text-[color:var(--accent)]"
>
{skill.displayName}
</Link>
<span className="dashboard-list-id">/{skill.slug}</span>
<span className="font-mono text-xs text-[color:var(--ink-soft)]">/{skill.slug}</span>
{skill.pendingReview ? (
<Badge variant="pending">
<Clock className="h-3 w-3" aria-hidden="true" />
@@ -311,35 +286,44 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
</Badge>
) : null}
</div>
<div className="dashboard-inline-metrics">
<span>
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
<div className="flex flex-wrap items-center gap-3 text-xs text-[color:var(--ink-soft)]">
<span className="inline-flex w-14 items-center justify-end gap-1 tabular-nums">
<ArrowDownToLine size={13} aria-hidden="true" />{" "}
{formatCompactStat(skill.stats.downloads)}
</span>
<span>
<span className="inline-flex w-14 items-center justify-end gap-1 tabular-nums">
<Star size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
</span>
<span>
<span className="inline-flex w-14 items-center justify-end gap-1 tabular-nums">
<Package size={13} aria-hidden="true" /> {skill.stats.versions}
</span>
</div>
</div>
<div className="dashboard-list-summary">{skill.summary ?? "No summary provided."}</div>
<div className="dashboard-list-status">
{/* Summary */}
<div className="text-sm text-[color:var(--ink-soft)]">
{skill.summary ?? "No summary provided."}
</div>
{/* Status */}
<div className="flex flex-col gap-1 text-xs text-[color:var(--ink-soft)]">
{skill.pendingReview ? (
<>
<span className="dashboard-inline-status-item">
<span className="inline-flex items-center gap-1">
<ShieldCheck size={13} aria-hidden="true" />
VT pending
</span>
<span className="dashboard-inline-status-note">
<span className="text-[color:var(--ink-soft)]">
Hidden until verification checks finish.
</span>
</>
) : (
<span className="dashboard-inline-status-note">Visible</span>
<span>Visible</span>
)}
</div>
<div className="dashboard-row-actions">
{/* Actions */}
<div className="flex flex-wrap gap-2">
<Button asChild size="sm">
<Link to="/publish-skill" search={{ updateSlug: skill.slug }}>
<Upload className="h-3 w-3" aria-hidden="true" />
@@ -347,10 +331,7 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link
to="/$owner/$slug"
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
>
<Link to="/$owner/$slug" params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}>
View
</Link>
</Button>
@@ -407,7 +388,9 @@ function PackageStatusTag({
function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle: string }) {
const scanLabel = scanStatusLabel(pkg.scanStatus);
const nextVersion = pkg.latestVersion ? semver.inc(pkg.latestVersion, "patch") : null;
const sourceLabel = pkg.sourceRepo?.replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, "");
const sourceLabel = pkg.sourceRepo
?.replace(/^https?:\/\/github\.com\//, "")
.replace(/\.git$/, "");
const scanTone =
pkg.scanStatus === "pending"
? "pending"
@@ -428,15 +411,20 @@ function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle:
: "default";
return (
<div className="dashboard-list-row">
<div className="dashboard-list-primary">
<div className="dashboard-list-title">
<Link to="/plugins/$name" params={{ name: pkg.name }} className="dashboard-skill-name">
<div className="grid items-start gap-4 border-b border-[color:var(--line)] px-4 py-4 last:border-b-0 md:grid-cols-[2fr_2fr_1.5fr_auto]">
{/* Primary info */}
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<Link
to="/plugins/$name"
params={{ name: pkg.name }}
className="font-display text-sm font-bold text-[color:var(--ink)] hover:text-[color:var(--accent)]"
>
{pkg.displayName}
</Link>
<span className="dashboard-list-id">{pkg.name}</span>
<span className="font-mono text-xs text-[color:var(--ink-soft)]">{pkg.name}</span>
</div>
<div className="dashboard-inline-tags">
<div className="flex flex-wrap gap-1.5">
<PackageStatusTag label={familyLabel(pkg.family)} tone="default" />
<PackageStatusTag label={pkg.channel} tone="default" />
{scanLabel ? <PackageStatusTag label={scanLabel} tone={scanTone} /> : null}
@@ -450,34 +438,41 @@ function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle:
/>
) : null}
</div>
<div className="dashboard-inline-metrics">
<span>
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.downloads)}
<div className="flex flex-wrap items-center gap-3 text-xs text-[color:var(--ink-soft)]">
<span className="inline-flex items-center gap-1">
<ArrowDownToLine size={13} aria-hidden="true" />{" "}
{formatCompactStat(pkg.stats.downloads)}
</span>
<span>
<span className="inline-flex items-center gap-1">
<Star size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.stars)}
</span>
<span>
<span className="inline-flex items-center gap-1">
<Package size={13} aria-hidden="true" /> {pkg.stats.versions}
</span>
<span>
<span className="inline-flex items-center gap-1">
<GitBranch size={13} aria-hidden="true" /> {pkg.latestVersion ?? "No tag"}
</span>
{pkg.runtimeId ? (
<span>
<span className="inline-flex items-center gap-1">
<Plug size={13} aria-hidden="true" /> {pkg.runtimeId}
</span>
) : null}
{sourceLabel ? (
<span>
<span className="inline-flex items-center gap-1">
<ShieldCheck size={13} aria-hidden="true" /> {sourceLabel}
</span>
) : null}
</div>
</div>
<div className="dashboard-list-summary">{pkg.summary ?? "No summary provided."}</div>
<div className="dashboard-list-status">
<span className="dashboard-inline-status-item">
{/* Summary */}
<div className="text-sm text-[color:var(--ink-soft)]">
{pkg.summary ?? "No summary provided."}
</div>
{/* Status */}
<div className="flex flex-col gap-1 text-xs text-[color:var(--ink-soft)]">
<span className="inline-flex items-center gap-1">
<ShieldCheck size={13} aria-hidden="true" />{" "}
{releaseStatusLabel(
"VT",
@@ -485,16 +480,18 @@ function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle:
pkg.scanStatus === "pending" ? "pending" : "unknown",
)}
</span>
<span className="dashboard-inline-status-item">
<span className="inline-flex items-center gap-1">
<CheckCircle2 size={13} aria-hidden="true" />{" "}
{releaseStatusLabel("LLM", pkg.latestRelease?.llmStatus)}
</span>
<span className="dashboard-inline-status-item">
<span className="inline-flex items-center gap-1">
<AlertTriangle size={13} aria-hidden="true" />{" "}
{releaseStatusLabel("Static", pkg.latestRelease?.staticScanStatus)}
</span>
</div>
<div className="dashboard-row-actions">
{/* Actions */}
<div className="flex flex-wrap gap-2">
<Button asChild size="sm">
<Link
to="/publish-plugin"
+289 -494
View File
@@ -1,32 +1,20 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useAction, useQuery } from "convex/react";
import {
ArrowRight,
Code2,
Download,
Flame,
Ghost,
Package,
Search,
Sparkles,
Star,
TrendingUp,
Users,
Zap,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { ArrowRight, Search } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { api } from "../../convex/_generated/api";
import { InstallSwitcher } from "../components/InstallSwitcher";
import { Container } from "../components/layout/Container";
import { SkillCardSkeletonGrid } from "../components/skeletons/SkillCardSkeleton";
import { SkillCard } from "../components/SkillCard";
import { SkillListItem } from "../components/SkillListItem";
import { SkillStatsTripletLine } from "../components/SkillStats";
import { SoulCard } from "../components/SoulCard";
import { SoulStatsTripletLine } from "../components/SoulStats";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { UserBadge } from "../components/UserBadge";
import { convexHttp } from "../convex/client";
import { getSkillBadges } from "../lib/badges";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill, PublicSoul } from "../lib/publicUser";
import { getSiteMode } from "../lib/site";
@@ -39,19 +27,6 @@ function Home() {
return mode === "souls" ? <OnlyCrabsHome /> : <SkillsHome />;
}
const popularSearches = ["AI Writing", "Screenshot", "Productivity", "Analytics", "Automation"];
const categories = [
{ name: "Productivity", icon: "⚡", count: 324, className: "productivity" },
{ name: "AI & ML", icon: "🧠", count: 218, className: "ai" },
{ name: "Developer Tools", icon: "💻", count: 456, className: "developer" },
{ name: "Design", icon: "🎨", count: 189, className: "design" },
{ name: "Analytics", icon: "📊", count: 142, className: "analytics" },
{ name: "Security", icon: "🔐", count: 98, className: "security" },
{ name: "Automation", icon: "⚙️", count: 276, className: "automation" },
{ name: "Media", icon: "🖼️", count: 167, className: "media" },
];
function SkillsHome() {
type SkillPageEntry = {
skill: PublicSkill;
@@ -61,430 +36,223 @@ function SkillsHome() {
};
const [highlighted, setHighlighted] = useState<SkillPageEntry[]>([]);
const [trending, setTrending] = useState<SkillPageEntry[]>([]);
const [recent, setRecent] = useState<SkillPageEntry[]>([]);
const [skillCount, setSkillCount] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const navigate = Route.useNavigate();
const [popular, setPopular] = useState<SkillPageEntry[]>([]);
const [loaded, setLoaded] = useState(false);
const totalSkills = useQuery(api.skills.countPublicSkills);
const totalSkillsText =
typeof totalSkills === "number" ? totalSkills.toLocaleString("en-US") : null;
useEffect(() => {
let cancelled = false;
Promise.all([
convexHttp.query(api.skills.listHighlightedPublic, { limit: 6 }),
convexHttp.query(api.skills.listPublicPageV4, {
numItems: 6,
sort: "downloads",
dir: "desc",
nonSuspiciousOnly: true,
}),
convexHttp.query(api.skills.listPublicPageV4, {
numItems: 6,
sort: "updated",
dir: "desc",
nonSuspiciousOnly: true,
}),
convexHttp.query(api.skills.countPublicSkills, {}),
])
.then(([h, t, r, c]) => {
if (cancelled) return;
setHighlighted((h as SkillPageEntry[]).slice(0, 6));
setTrending((t as { page: SkillPageEntry[] }).page);
setRecent((r as { page: SkillPageEntry[] }).page);
setSkillCount(c as number);
})
.catch(() => {});
void Promise.all([
convexHttp
.query(api.skills.listHighlightedPublic, { limit: 6 })
.then((r) => {
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);
})
.catch(() => {}),
]).then(() => {
if (!cancelled) setLoaded(true);
});
return () => {
cancelled = true;
};
}, []);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const q = searchQuery.trim();
if (!q) return;
void navigate({
to: "/search",
search: { q, type: undefined },
});
};
const highlightedGridStyle = {
"--staff-picks-cols-lg": String(Math.max(1, Math.min(highlighted.length, 6))),
"--staff-picks-cols-md": String(Math.max(1, Math.min(highlighted.length, 3))),
"--staff-picks-cols-sm": String(Math.max(1, Math.min(highlighted.length, 2))),
"--staff-picks-cols-xs": "1",
} as CSSProperties;
return (
<main>
{/* Hero Section */}
<section className="home-hero">
<div className="home-hero-inner">
<div className="home-hero-grid">
<div className="home-hero-copy">
{/* Badge */}
<div className="home-hero-kicker">
<Sparkles size={14} className="home-hero-kicker-icon" />
<span>
{skillCount != null
? `${formatCompactStat(skillCount)} curated tools`
: "Thousands of curated tools"}
</span>
</div>
{/* Headline */}
<h1 className="home-hero-title">
Discover tools that{" "}
<span className="home-hero-title-accent">power your work</span>
{/* Hero */}
<section className="relative overflow-hidden py-20 px-7">
<div className="mx-auto max-w-[1200px]">
<div className="grid items-center gap-10 md:grid-cols-[1.15fr_1fr]">
<div className="flex flex-col gap-5 fade-up" data-delay="1">
<span className="inline-flex w-fit items-center gap-2 rounded-[var(--radius-pill)] bg-[color:var(--accent)]/10 px-4 py-1.5 text-xs font-bold text-[color:var(--accent)]">
A versioned registry for AI agent skills
</span>
<h1 className="font-display text-[clamp(2.2rem,4vw,3.6rem)] font-extrabold leading-[1.1] tracking-tight text-[color:var(--ink)]">
ClawHub, the skill dock for sharp agents.
</h1>
{/* Subheadline */}
<p className="home-hero-subtitle">
The modern marketplace for internet tools. Find, compare, and install the best
software to supercharge your productivity.
<p className="max-w-lg text-lg leading-relaxed text-[color:var(--ink-soft)]">
Browse, install, and publish skill packs. Versioned like npm, searchable with
vectors, no gatekeeping.
</p>
{/* Search */}
<form className="home-hero-search" onSubmit={handleSearch}>
<div className="home-hero-search-wrapper">
<Search size={20} className="home-hero-search-icon" />
<input
type="text"
className="home-hero-search-input"
placeholder="Search for tools, categories, or features..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<button type="submit" className="home-hero-search-btn">
<span>Search</span>
<ArrowRight size={16} />
</button>
</div>
</form>
{/* Popular searches */}
<div className="home-hero-popular">
<span className="home-hero-popular-label">Popular:</span>
{popularSearches.map((search) => (
<button
key={search}
type="button"
className="home-hero-popular-tag"
onClick={() => setSearchQuery(search)}
>
{search}
</button>
))}
{/* Stats bar */}
{totalSkillsText && (
<p className="text-sm font-semibold text-[color:var(--ink-soft)]">
{totalSkillsText} skills available
</p>
)}
<div className="flex flex-wrap gap-3 pt-2">
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
>
<Button variant="primary" size="lg">
Browse skills
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Button variant="outline" size="lg">
Publish Skill
</Button>
</Link>
</div>
</div>
<div className="hero-card hero-search-card fade-up" data-delay="2">
<div className="hero-install" style={{ marginTop: 18 }}>
<div className="text-sm font-semibold text-[color:var(--ink-soft)]">
Search skills. Versioned, rollback-ready.
</div>
<InstallSwitcher exampleSlug="sonoscli" />
</div>
</div>
</div>
</div>
</section>
{/* Discovery Panels */}
<div className="home-hero-panels" id="home-discovery">
{/* Staff Picks */}
<section className="py-12">
<Container>
<div className="flex flex-col gap-6">
<div className="flex items-end justify-between">
<div>
<h2 className="font-display text-xl font-bold text-[color:var(--ink)]">
Staff Picks
</h2>
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
Curated signal highlighted for quick trust.
</p>
</div>
<Link
to="/skills"
search={{
q: undefined,
sort: "downloads" as const,
dir: "desc" as const,
sort: undefined,
dir: undefined,
highlighted: true,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
className="hidden text-sm font-semibold text-[color:var(--accent)] hover:underline sm:block"
>
View all
</Link>
</div>
{!loaded && highlighted.length === 0 ? (
<SkillCardSkeletonGrid count={6} />
) : highlighted.length === 0 ? (
<p className="text-sm text-[color:var(--ink-soft)]">No highlighted skills yet.</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
{highlighted.map((entry) => (
<SkillCard
key={entry.skill._id}
skill={entry.skill}
badge={getSkillBadges(entry.skill)}
summaryFallback="A fresh skill bundle."
meta={
<>
<UserBadge
user={entry.owner}
fallbackHandle={entry.ownerHandle ?? null}
prefix="by"
link={false}
/>
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
<SkillStatsTripletLine stats={entry.skill.stats} />
</span>
</>
}
/>
))}
</div>
)}
</div>
</Container>
</section>
{/* Popular */}
<section className="py-12">
<Container>
<div className="flex flex-col gap-6">
<div>
<h2 className="font-display text-xl font-bold text-[color:var(--ink)]">
Popular skills
</h2>
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
Most-downloaded, verified picks.
</p>
</div>
{!loaded && popular.length === 0 ? (
<SkillCardSkeletonGrid count={6} />
) : popular.length === 0 ? (
<p className="text-sm text-[color:var(--ink-soft)]">No skills yet. Be the first.</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
{popular.map((entry) => (
<SkillCard
key={entry.skill._id}
skill={entry.skill}
summaryFallback="Agent-ready skill pack."
meta={
<>
<UserBadge
user={entry.owner}
fallbackHandle={entry.ownerHandle ?? null}
prefix="by"
link={false}
/>
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
<SkillStatsTripletLine stats={entry.skill.stats} />
</span>
</>
}
/>
))}
</div>
)}
<div className="flex justify-center pt-4">
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
className="home-hero-panel"
>
<div className="home-hero-panel-icon">
<Zap size={20} />
</div>
<strong>Skills</strong>
<span>Browse ranked skill bundles</span>
</Link>
<Link to="/plugins" className="home-hero-panel">
<div className="home-hero-panel-icon">
<Code2 size={20} />
</div>
<strong>Plugins</strong>
<span>Agent-ready packages</span>
</Link>
<Link to="/users" search={{ q: undefined }} className="home-hero-panel">
<div className="home-hero-panel-icon">
<Users size={20} />
</div>
<strong>Builders</strong>
<span>Meet the creators</span>
</Link>
<Link
to="/souls"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
className="home-hero-panel"
>
<div className="home-hero-panel-icon">
<Ghost size={20} />
</div>
<strong>Souls</strong>
<span>SOUL.md discovery</span>
<Button variant="outline">
See all skills
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
</div>
</div>
</section>
{/* Stats Bar */}
<section className="home-section">
<div className="home-stats">
<div className="home-stat">
<div className="home-stat-value">
{skillCount != null ? formatCompactStat(skillCount) : "2.4K"}+
</div>
<div className="home-stat-label">Curated Tools</div>
</div>
<div className="home-stat">
<div className="home-stat-value">180K+</div>
<div className="home-stat-label">Active Users</div>
</div>
<div className="home-stat">
<div className="home-stat-value">12M+</div>
<div className="home-stat-label">Total Downloads</div>
</div>
<div className="home-stat">
<div className="home-stat-value">4.8</div>
<div className="home-stat-label">Avg. Rating</div>
</div>
</div>
</section>
{/* Trending */}
{trending.length > 0 ? (
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">
<span className="home-section-title-icon trending">
<TrendingUp size={16} />
</span>
Trending Now
</h2>
<Link
to="/skills"
search={{
q: undefined,
sort: "downloads" as const,
dir: "desc" as const,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
className="home-section-link"
>
View all
<ArrowRight size={14} />
</Link>
</div>
<div className="results-list">
{trending.map((entry) => (
<SkillListItem
key={entry.skill._id}
skill={entry.skill}
ownerHandle={entry.ownerHandle}
owner={entry.owner}
/>
))}
</div>
</section>
) : null}
{/* Recently updated */}
{recent.length > 0 ? (
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">
<span className="home-section-title-icon recent">
<Sparkles size={16} />
</span>
Recently Updated
</h2>
<Link
to="/skills"
search={{
q: undefined,
sort: "updated" as const,
dir: "desc" as const,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
className="home-section-link"
>
View all
<ArrowRight size={14} />
</Link>
</div>
<div className="results-list">
{recent.map((entry) => (
<SkillListItem
key={entry.skill._id}
skill={entry.skill}
ownerHandle={entry.ownerHandle}
owner={entry.owner}
/>
))}
</div>
</section>
) : null}
{/* Staff picks */}
{highlighted.length > 0 ? (
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">
<span className="home-section-title-icon featured">
<Star size={16} />
</span>
Staff Picks
</h2>
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: true,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
className="home-section-link"
>
View all
<ArrowRight size={14} />
</Link>
</div>
<div className="home-staff-picks-grid" style={highlightedGridStyle}>
{highlighted.map((entry) => (
<SkillCard
key={entry.skill._id}
skill={entry.skill}
badge={getSkillBadges(entry.skill)}
summaryFallback="A fresh skill bundle."
meta={
<div className="skill-card-footer-rows">
<UserBadge
user={entry.owner}
fallbackHandle={entry.ownerHandle ?? null}
prefix="by"
link={false}
/>
<div className="stat">
<SkillStatsTripletLine stats={entry.skill.stats} />
</div>
</div>
}
/>
))}
</div>
</section>
) : null}
{/* Categories */}
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">Browse by Category</h2>
</div>
<div className="home-categories">
{categories.map((category) => (
<Link
key={category.name}
to="/skills"
search={{
q: category.name,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
className="home-category-card"
>
<div className={`home-category-icon ${category.className}`}>{category.icon}</div>
<div className="home-category-content">
<div className="home-category-name">{category.name}</div>
<div className="home-category-count">{category.count} tools</div>
</div>
</Link>
))}
</div>
</section>
{/* Quick links */}
<section className="home-section">
<div className="home-quick-links">
<Link
to="/skills"
search={{
q: undefined,
sort: "stars" as const,
dir: "desc" as const,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
className="home-quick-link"
>
<Star size={14} className="home-quick-link-icon" />
Most starred
</Link>
<Link
to="/skills"
search={{
q: undefined,
sort: "newest" as const,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
className="home-quick-link"
>
<Sparkles size={14} className="home-quick-link-icon" />
New this week
</Link>
<Link to="/plugins" className="home-quick-link">
<Code2 size={14} className="home-quick-link-icon" />
Browse plugins
</Link>
<Link to="/users" search={{ q: undefined }} className="home-quick-link">
<Users size={14} className="home-quick-link-icon" />
Browse users
</Link>
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: true,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
className="home-quick-link"
>
<Star size={14} className="home-quick-link-icon" />
Staff picks
</Link>
</div>
</Container>
</section>
</main>
);
@@ -506,23 +274,45 @@ function OnlyCrabsHome() {
return (
<main>
<section className="home-hero">
<div className="home-hero-inner">
<div className="home-hero-grid">
<div className="home-hero-copy">
<div className="home-hero-kicker">
<Ghost size={14} className="home-hero-kicker-icon" />
<span>OnlyCrabs</span>
</div>
<h1 className="home-hero-title">
<span className="home-hero-title-accent">SoulHub</span>, where system lore lives.
<section className="relative overflow-hidden py-20 px-7">
<div className="mx-auto max-w-[1200px]">
<div className="grid items-center gap-10 md:grid-cols-[1.15fr_1fr]">
<div className="flex flex-col gap-5 fade-up" data-delay="1">
<span className="inline-flex w-fit items-center gap-2 rounded-[var(--radius-pill)] bg-[color:var(--accent)]/10 px-4 py-1.5 text-xs font-bold text-[color:var(--accent)]">
SOUL.md, shared.
</span>
<h1 className="font-display text-[clamp(2.2rem,4vw,3.6rem)] font-extrabold leading-[1.1] tracking-tight text-[color:var(--ink)]">
SoulHub, where system lore lives.
</h1>
<p className="home-hero-subtitle">
<p className="max-w-lg text-lg leading-relaxed text-[color:var(--ink-soft)]">
Share SOUL.md bundles, version them like docs, and keep personal system lore in one
public place.
</p>
<div className="flex flex-wrap gap-3 pt-2">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Button variant="primary" size="lg">
Publish Soul
</Button>
</Link>
<Link
to="/souls"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
>
<Button variant="outline" size="lg">
Browse souls
</Button>
</Link>
</div>
</div>
<div className="hero-card hero-search-card fade-up" data-delay="2">
<form
className="home-hero-search"
className="search-bar"
onSubmit={(event) => {
event.preventDefault();
void navigate({
@@ -537,67 +327,72 @@ function OnlyCrabsHome() {
});
}}
>
<div className="home-hero-search-wrapper">
<Search size={20} className="home-hero-search-icon" />
<input
className="home-hero-search-input"
type="text"
placeholder="Search souls, prompts, or lore"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<button type="submit" className="home-hero-search-btn">
<span>Search</span>
<ArrowRight size={16} />
</button>
</div>
<Search className="h-4 w-4 text-[color:var(--ink-soft)]" />
<Input
placeholder="Search souls, prompts, or lore"
value={query}
onChange={(event) => setQuery(event.target.value)}
className="border-none bg-transparent shadow-none focus:shadow-none focus:ring-0"
/>
</form>
<div className="hero-install" style={{ marginTop: 18 }}>
<div className="text-sm font-semibold text-[color:var(--ink-soft)]">
Search souls. Versioned, readable, easy to remix.
</div>
</div>
</div>
</div>
</div>
</section>
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">
<span className="home-section-title-icon recent">
<Sparkles size={16} />
</span>
Latest Souls
</h2>
<Link
to="/souls"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
className="home-section-link"
>
View all
<ArrowRight size={14} />
</Link>
</div>
<div className="grid">
{latest.length === 0 ? (
<Card>No souls yet. Be the first.</Card>
) : (
latest.map((soul) => (
<SoulCard
key={soul._id}
soul={soul}
summaryFallback="A SOUL.md bundle."
meta={
<div className="stat">
<SoulStatsTripletLine stats={soul.stats} />
</div>
}
/>
))
)}
</div>
<section className="py-12">
<Container>
<div className="flex flex-col gap-6">
<div>
<h2 className="font-display text-xl font-bold text-[color:var(--ink)]">
Latest souls
</h2>
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
Newest SOUL.md bundles across the hub.
</p>
</div>
{latest.length === 0 ? (
<SkillCardSkeletonGrid count={6} />
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
{latest.map((soul) => (
<SoulCard
key={soul._id}
soul={soul}
summaryFallback="A SOUL.md bundle."
meta={
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
<SoulStatsTripletLine stats={soul.stats} />
</span>
}
/>
))}
</div>
)}
<div className="flex justify-center pt-4">
<Link
to="/souls"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
>
<Button variant="outline">
See all souls
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
</div>
</Container>
</section>
</main>
);
+798 -642
View File
File diff suppressed because it is too large Load Diff
+68 -37
View File
@@ -14,6 +14,7 @@ import {
fetchPackageReadme,
fetchPackageVersion,
getPackageDownloadPath,
isRateLimitedPackageApiError,
type PackageDetailResponse,
type PackageVersionDetail,
} from "../../lib/packageApi";
@@ -35,7 +36,6 @@ type PluginDetailLoaderData = {
export const Route = createFileRoute("/plugins/$name")({
loader: async ({ params }): Promise<PluginDetailLoaderData> => {
// All fetch functions now handle errors internally and return null/empty on failure
const requestedName = params.name;
const candidateNames = requestedName.includes("/")
? [requestedName]
@@ -43,9 +43,24 @@ export const Route = createFileRoute("/plugins/$name")({
let resolvedName = requestedName;
let detail: PackageDetailResponse = { package: null, owner: null };
for (const candidateName of candidateNames) {
const candidateDetail = await fetchPackageDetail(candidateName);
let candidateDetail: PackageDetailResponse;
try {
candidateDetail = await fetchPackageDetail(candidateName);
} catch (error) {
if (isRateLimitedPackageApiError(error)) {
return {
detail: { package: null, owner: null },
version: null,
readme: null,
rateLimited: {
scope: "detail",
retryAfterSeconds: error.retryAfterSeconds,
},
};
}
throw error;
}
if (candidateDetail.package) {
detail = candidateDetail;
resolvedName = candidateName;
@@ -55,18 +70,35 @@ export const Route = createFileRoute("/plugins/$name")({
}
if (!detail.package) {
return { detail, version: null, readme: null, rateLimited: null };
return {
detail,
version: null,
readme: null,
rateLimited: null,
};
}
// Fetch readme and version in parallel - functions handle errors internally
const [version, readme] = await Promise.all([
detail.package.latestVersion
? fetchPackageVersion(resolvedName, detail.package.latestVersion)
: Promise.resolve(null),
fetchPackageReadme(resolvedName),
]);
return { detail, version, readme, rateLimited: null };
let metadataRateLimited: PluginDetailRateLimitState = null;
const readmePromise = fetchPackageReadme(resolvedName).catch((error: unknown) => {
if (!isRateLimitedPackageApiError(error)) throw error;
metadataRateLimited ??= {
scope: "metadata",
retryAfterSeconds: error.retryAfterSeconds,
};
return null;
});
const versionPromise = detail.package?.latestVersion
? fetchPackageVersion(resolvedName, detail.package.latestVersion).catch((error: unknown) => {
if (!isRateLimitedPackageApiError(error)) throw error;
metadataRateLimited ??= {
scope: "metadata",
retryAfterSeconds: error.retryAfterSeconds,
};
return null;
})
: Promise.resolve(null);
const [version, readme] = await Promise.all([versionPromise, readmePromise]);
return { detail, version, readme, rateLimited: metadataRateLimited };
},
head: ({ params, loaderData }) => ({
meta: [
@@ -264,8 +296,9 @@ function PluginDetailRoute() {
: [];
return (
<main className="section">
<div className="flex min-w-0 flex-col gap-5">
<main className="py-10">
<Container>
<div className="flex flex-col gap-5">
{/* Header card */}
<Card>
<CardContent>
@@ -283,7 +316,7 @@ function PluginDetailRoute() {
</Badge>
) : null}
</div>
<h1 className="mb-1 break-words font-display text-2xl font-bold text-[color:var(--ink)]">
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)] mb-1">
{pkg.displayName}
{pkg.latestVersion ? (
<span className="ml-2 inline-block rounded-[var(--radius-pill)] bg-[color:var(--surface-muted)] px-2 py-0.5 text-xs font-semibold text-[color:var(--ink-soft)]">
@@ -291,16 +324,16 @@ function PluginDetailRoute() {
</span>
) : null}
</h1>
<p className="mb-2 break-words text-sm text-[color:var(--ink-soft)]">
<p className="text-sm text-[color:var(--ink-soft)] mb-2">
{pkg.summary ?? "No summary provided."}
</p>
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm text-[color:var(--ink-soft)]">
<span className="break-all font-mono text-xs">{pkg.name}</span>
<div className="flex flex-wrap items-center gap-2 text-sm text-[color:var(--ink-soft)]">
<span className="font-mono text-xs">{pkg.name}</span>
{pkg.runtimeId ? (
<>
<span className="opacity-40">&middot;</span>
<span>
runtime <span className="break-all font-mono text-xs">{pkg.runtimeId}</span>
runtime <span className="font-mono text-xs">{pkg.runtimeId}</span>
</span>
</>
) : null}
@@ -327,7 +360,7 @@ function PluginDetailRoute() {
{/* Install */}
<div className="mt-4">
<div className="flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3 sm:flex-row sm:items-center sm:gap-2">
<pre className="plugin-detail-code-block min-w-0 flex-1 font-mono text-xs text-[color:var(--ink)]">
<pre className="min-w-0 flex-1 overflow-x-auto font-mono text-xs text-[color:var(--ink)]">
<code>{installSnippet}</code>
</pre>
<CopyButton text={installSnippet} />
@@ -340,12 +373,13 @@ function PluginDetailRoute() {
<span className="text-sm">
Latest release: <strong>v{pkg.latestVersion}</strong>
</span>
<Button asChild variant="outline" size="sm" className="w-full no-underline sm:w-auto">
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
Download zip
</a>
</Button>
<a
href={getPackageDownloadPath(name, pkg.latestVersion)}
className="inline-flex min-h-[34px] w-full items-center justify-center gap-2 rounded-[var(--radius-pill)] border border-[color:var(--border-ui)] bg-transparent px-3 py-1.5 text-xs font-semibold text-[color:var(--ink)] transition-all duration-200 no-underline hover:border-[color:var(--border-ui-hover)] hover:bg-[color:var(--surface)] sm:w-auto sm:whitespace-nowrap"
>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
Download zip
</a>
</div>
) : null}
</CardContent>
@@ -365,7 +399,7 @@ function PluginDetailRoute() {
<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)]">
<dd className="text-[color:var(--ink)]">
{key === "capabilityTags" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((tag) => (
@@ -407,9 +441,7 @@ function PluginDetailRoute() {
<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)]">
{String(value)}
</dd>
<dd className="font-mono text-xs text-[color:var(--ink)]">{String(value)}</dd>
</div>
))}
</dl>
@@ -474,7 +506,7 @@ function PluginDetailRoute() {
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"
className="inline-flex items-center gap-1 text-[color:var(--accent)] hover:underline"
>
{display}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
@@ -487,7 +519,7 @@ function PluginDetailRoute() {
{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)]">
<dd className="font-mono text-xs text-[color:var(--ink)]">
{verification.sourceCommit.slice(0, 12)}
</dd>
</div>
@@ -495,7 +527,7 @@ function PluginDetailRoute() {
{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)]">
<dd className="font-mono text-xs text-[color:var(--ink)]">
{verification.sourceTag}
</dd>
</div>
@@ -530,9 +562,7 @@ function PluginDetailRoute() {
{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>
<dd className="font-mono text-xs text-[color:var(--ink)]">{value}</dd>
</div>
))}
</dl>
@@ -549,6 +579,7 @@ function PluginDetailRoute() {
</Card>
) : null}
</div>
</Container>
</main>
);
}
+263 -193
View File
@@ -1,13 +1,20 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router";
import { AlertTriangle, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { BrowseSidebar } from "../../components/BrowseSidebar";
import { PluginListItem } from "../../components/PluginListItem";
import { EmptyState } from "../../components/EmptyState";
import { Container } from "../../components/layout/Container";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { formatRetryDelay } from "../../lib/formatRetryDelay";
import {
fetchPluginCatalog,
isRateLimitedPackageApiError,
type PackageListItem,
} from "../../lib/packageApi";
import { familyLabel } from "../../lib/packageLabels";
type PluginSearchState = {
q?: string;
@@ -22,18 +29,8 @@ type PluginsLoaderData = {
nextCursor: string | null;
rateLimited: boolean;
retryAfterSeconds: number | null;
apiError?: boolean;
};
function formatRetryDelay(retryAfterSeconds: number | null) {
if (!retryAfterSeconds || retryAfterSeconds <= 0) return "in a moment";
if (retryAfterSeconds < 60) {
return `in about ${retryAfterSeconds} second${retryAfterSeconds === 1 ? "" : "s"}`;
}
const minutes = Math.ceil(retryAfterSeconds / 60);
return `in about ${minutes} minute${minutes === 1 ? "" : "s"}`;
}
export const Route = createFileRoute("/plugins/")({
validateSearch: (search): PluginSearchState => ({
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
@@ -47,112 +44,111 @@ export const Route = createFileRoute("/plugins/")({
? true
: undefined,
executesCode:
search.executesCode === true ||
search.executesCode === "true" ||
search.executesCode === "1"
search.executesCode === true || search.executesCode === "true" || search.executesCode === "1"
? true
: undefined,
}),
loaderDeps: ({ search }) => search,
loader: async ({ deps }): Promise<PluginsLoaderData> => {
// fetchPluginCatalog now handles errors internally and returns empty results
const data = await fetchPluginCatalog({
q: deps.q,
cursor: deps.q ? undefined : deps.cursor,
family: deps.family,
isOfficial: deps.verified,
executesCode: deps.executesCode,
limit: 50,
});
const items = data?.items ?? [];
return {
items,
nextCursor: data?.nextCursor ?? null,
rateLimited: false,
retryAfterSeconds: null,
apiError: items.length === 0 && !deps.q && !deps.family && !deps.verified && !deps.executesCode,
};
loader: async ({ deps }) => {
try {
const data = await fetchPluginCatalog({
q: deps.q,
cursor: deps.q ? undefined : deps.cursor,
family: deps.family,
isOfficial: deps.verified,
executesCode: deps.executesCode,
limit: 50,
});
return {
items: data.items,
nextCursor: data.nextCursor,
rateLimited: false,
retryAfterSeconds: null,
} satisfies PluginsLoaderData;
} catch (error) {
if (!isRateLimitedPackageApiError(error)) throw error;
return {
items: [],
nextCursor: null,
rateLimited: true,
retryAfterSeconds: error.retryAfterSeconds,
} satisfies PluginsLoaderData;
}
},
component: PluginsIndex,
});
function PluginsIndex() {
function VerifiedBadge() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="Verified publisher"
className="inline-block shrink-0 align-middle"
>
<path
d="M8 0L9.79 1.52L12.12 1.21L12.93 3.41L15.01 4.58L14.42 6.84L15.56 8.82L14.12 10.5L14.12 12.82L11.86 13.41L10.34 15.27L8 14.58L5.66 15.27L4.14 13.41L1.88 12.82L1.88 10.5L0.44 8.82L1.58 6.84L0.99 4.58L3.07 3.41L3.88 1.21L6.21 1.52L8 0Z"
fill="#3b82f6"
/>
<path
d="M5.5 8L7 9.5L10.5 6"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export 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 { items, nextCursor, rateLimited, retryAfterSeconds } =
Route.useLoaderData() as PluginsLoaderData;
const [query, setQuery] = useState(search.q ?? "");
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
setQuery(search.q ?? "");
}, [search.q]);
const handleFilterToggle = (key: string) => {
if (key === "verified") {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
verified: prev.verified ? undefined : true,
}),
});
} else if (key === "executesCode") {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
executesCode: prev.executesCode ? undefined : true,
}),
});
}
};
const handleFamilySort = (value: string) => {
const family =
value === "code-plugin" || value === "bundle-plugin" ? value : undefined;
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
family: family as "code-plugin" | "bundle-plugin" | undefined,
}),
});
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
}),
});
};
return (
<main className="browse-page">
<div className="browse-page-header">
<button
className="browse-sidebar-toggle"
type="button"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label="Toggle filters"
>
Filters
</button>
<h1 className="browse-title">Plugins</h1>
<div className="browse-page-actions">
<Button asChild variant="primary">
<main className="py-10">
<Container size="wide">
<header className="mb-6">
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)] mb-2">Plugins</h1>
<p className="text-sm text-[color:var(--ink-soft)]">Browse the plugin catalog.</p>
</header>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<form
className="relative flex flex-1 items-center"
onSubmit={(event) => {
event.preventDefault();
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
}),
});
}}
>
<Search
className="pointer-events-none absolute left-3 h-4 w-4 text-[color:var(--ink-soft)] opacity-50"
aria-hidden="true"
/>
<Input
className="pl-9"
placeholder="Search plugins..."
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</form>
<Link
to="/publish-plugin"
search={{
@@ -163,103 +159,177 @@ function PluginsIndex() {
nextVersion: undefined,
sourceRepo: undefined,
}}
className="inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold text-sm min-h-[44px] rounded-[var(--radius-pill)] px-4 py-[11px] border-none bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] text-white transition-all duration-200 no-underline hover:-translate-y-px hover:shadow-[0_10px_20px_rgba(29,26,23,0.12)]"
>
Publish
Publish Plugin
</Link>
</Button>
</div>
</div>
<form className="browse-page-search" onSubmit={handleSearch}>
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
<input
className="browse-search-input"
placeholder="Search plugins..."
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</form>
<div className={`browse-layout${sidebarOpen ? " sidebar-open" : ""}`}>
<BrowseSidebar
sortOptions={[
{ value: "all", label: "All types" },
{ value: "code-plugin", label: "Code plugins" },
{ value: "bundle-plugin", label: "Bundle plugins" },
]}
activeSort={search.family ?? "all"}
onSortChange={handleFamilySort}
filters={[
{ key: "verified", label: "Verified only", active: search.verified ?? false },
{ key: "executesCode", label: "Executes code", active: search.executesCode ?? false },
]}
onFilterToggle={handleFilterToggle}
/>
<div className="browse-results">
<div className="browse-results-toolbar">
<span className="browse-results-count">
{items.length} plugin{items.length !== 1 ? "s" : ""}
</span>
</div>
{apiError ? (
<div className="empty-state">
<AlertTriangle size={20} aria-hidden="true" />
<p className="empty-state-title">Unable to load plugins</p>
<p className="empty-state-body">
The plugin catalog is temporarily unavailable. Please try again later.
</p>
</div>
) : rateLimited ? (
<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>
</div>
) : items.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">No plugins found</p>
<p className="empty-state-body">Try a different search term or remove filters.</p>
</div>
) : (
<div className="results-list">
{items.map((item) => (
<PluginListItem key={item.name} item={item} />
<div className="flex flex-wrap items-stretch gap-2">
<div
className="flex min-w-0 flex-wrap items-center rounded-[var(--radius-pill)] border border-[color:var(--line)]"
role="group"
aria-label="Filter by type"
>
{[
{ value: undefined, label: "All" },
{ value: "code-plugin" as const, label: "Code" },
{ value: "bundle-plugin" as const, label: "Bundles" },
].map((opt) => (
<button
key={opt.label}
className={`flex-1 px-3 py-1.5 text-sm font-semibold transition-colors first:rounded-l-[var(--radius-pill)] last:rounded-r-[var(--radius-pill)] sm:flex-none ${
(search.family ?? undefined) === opt.value
? "bg-[color:var(--accent)] text-white"
: "text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
type="button"
aria-pressed={(search.family ?? undefined) === opt.value}
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
family: opt.value,
}),
});
}}
>
{opt.label}
</button>
))}
</div>
)}
{!search.q && (search.cursor || nextCursor) ? (
<div className="mt-5 flex justify-center gap-3">
{search.cursor ? (
<Button
type="button"
onClick={() => {
void navigate({
search: (prev) => ({ ...prev, cursor: undefined }),
});
}}
>
First page
</Button>
) : null}
{nextCursor ? (
<Button
variant="primary"
type="button"
onClick={() => {
void navigate({
search: (prev) => ({ ...prev, cursor: nextCursor }),
});
}}
>
Next page
</Button>
) : null}
</div>
) : null}
<Button
variant={search.verified ? "primary" : "outline"}
size="sm"
className="flex-1 sm:flex-none"
aria-pressed={search.verified ?? false}
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
verified: prev.verified ? undefined : true,
}),
});
}}
>
<VerifiedBadge /> Verified
</Button>
<Button
variant={search.executesCode ? "primary" : "outline"}
size="sm"
className="flex-1 sm:flex-none"
aria-pressed={search.executesCode ?? false}
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
executesCode: prev.executesCode ? undefined : true,
}),
});
}}
>
Executes code
</Button>
</div>
</div>
</div>
<div className="mt-6">
{rateLimited ? (
<EmptyState
icon={AlertTriangle}
title="Plugin catalog is temporarily unavailable"
description={`The public plugin API is rate-limited right now. Try again ${formatRetryDelay(
retryAfterSeconds,
)}.`}
action={{
label: "Try again",
onClick: () => window.location.reload(),
}}
/>
) : items.length === 0 ? (
<EmptyState
title="No plugins match that filter"
description="Try a different search or filter."
/>
) : (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fill,minmax(280px,1fr))] sm:gap-5">
{items.map((item) => (
<Link key={item.name} to="/plugins/$name" params={{ name: item.name }}>
<Card className="h-full cursor-pointer hover:-translate-y-px hover:shadow-[0_10px_20px_rgba(29,26,23,0.12)]">
<div className="flex flex-wrap gap-1.5">
<Badge variant="compact">{familyLabel(item.family)}</Badge>
{item.isOfficial ? (
<Badge variant="accent">
<VerifiedBadge /> Verified
</Badge>
) : null}
</div>
<h3 className="font-display text-lg font-bold text-[color:var(--ink)]">
{item.displayName}
</h3>
<p className="text-sm text-[color:var(--ink-soft)]">
{item.summary ?? "No summary provided."}
</p>
<div className="flex flex-col gap-1.5 pt-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 break-words text-sm text-[color:var(--ink-soft)]">
{item.ownerHandle ? `by ${item.ownerHandle}` : "community"}
</span>
{item.latestVersion ? (
<span className="text-sm text-[color:var(--ink-soft)]">
v{item.latestVersion}
</span>
) : null}
</div>
</Card>
</Link>
))}
</div>
{!search.q && (search.cursor || nextCursor) ? (
<div className="mt-6 flex flex-col items-stretch justify-center gap-3 sm:flex-row sm:items-center">
{search.cursor ? (
<Button
variant="outline"
className="w-full sm:w-auto"
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
}),
});
}}
>
First page
</Button>
) : null}
{nextCursor ? (
<Button
variant="primary"
className="w-full sm:w-auto"
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: nextCursor,
}),
});
}}
>
Next page
</Button>
) : null}
</div>
) : null}
</>
)}
</div>
</Container>
</main>
);
}
+35 -164
View File
@@ -1,171 +1,42 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Search } from "lucide-react";
import { 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 {
useUnifiedSearch,
type UnifiedPluginResult,
type UnifiedSkillResult,
type UnifiedUserResult,
} from "../lib/useUnifiedSearch";
type SearchState = {
q?: string;
type?: "all" | "skills" | "plugins" | "users";
};
import { createFileRoute, redirect } from "@tanstack/react-router";
import { detectSiteMode } from "../lib/site";
export const Route = createFileRoute("/search")({
validateSearch: (search): SearchState => ({
validateSearch: (search) => ({
q: typeof search.q === "string" && search.q.trim() ? search.q : undefined,
type:
search.type === "skills" || search.type === "plugins" || search.type === "users"
? search.type
: undefined,
highlighted: search.highlighted === "1" || search.highlighted === "true" ? true : undefined,
nonSuspicious:
search.nonSuspicious === "1" || search.nonSuspicious === "true" ? true : undefined,
}),
component: UnifiedSearchPage,
});
beforeLoad: ({ search, location }) => {
const hostname =
(location as { url?: URL }).url?.hostname ??
(typeof window !== "undefined" ? window.location.hostname : undefined);
const mode = detectSiteMode(hostname);
if (mode === "skills") {
throw redirect({
to: "/skills",
search: {
q: search.q || undefined,
sort: undefined,
dir: undefined,
highlighted: search.highlighted || undefined,
nonSuspicious: search.nonSuspicious || undefined,
view: undefined,
focus: undefined,
},
replace: true,
});
}
function UnifiedSearchPage() {
const search = Route.useSearch();
const navigate = useNavigate();
const activeType = search.type ?? "all";
const [query, setQuery] = useState(search.q ?? "");
const { results, skillCount, pluginCount, userCount, isSearching } = useUnifiedSearch(
search.q ?? "",
activeType,
);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
void navigate({
to: "/search",
search: { q: query.trim() || undefined, type: search.type },
});
};
const setType = (type: "all" | "skills" | "plugins" | "users") => {
void navigate({
to: "/search",
search: { q: search.q, type: type === "all" ? undefined : type },
throw redirect({
to: "/",
search: {
q: search.q || undefined,
highlighted: undefined,
search: search.q ? undefined : true,
},
replace: true,
});
};
return (
<main className="browse-page">
<h1 className="browse-title mb-4">
{search.q ? (
<>
Search results for <span className="text-[color:var(--accent)]">"{search.q}"</span>
</>
) : (
"Search"
)}
</h1>
<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" />
<input
className="browse-search-input"
type="text"
placeholder="Search skills, plugins, users..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
</div>
</form>
<div className="search-tabs">
<button
className={`search-tab${activeType === "all" ? " is-active" : ""}`}
type="button"
onClick={() => setType("all")}
>
All
</button>
<button
className={`search-tab${activeType === "skills" ? " is-active" : ""}`}
type="button"
onClick={() => setType("skills")}
>
Skills
{skillCount > 0 ? (
<span className="search-tab-count">{skillCount}</span>
) : null}
</button>
<button
className={`search-tab${activeType === "plugins" ? " is-active" : ""}`}
type="button"
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}
</button>
</div>
{isSearching ? (
<Card>
<div className="loading-indicator">Searching...</div>
</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>
</Card>
) : results.length === 0 ? (
<Card className="text-center p-10">
<p className="text-ink-soft">No results found for "{search.q}"</p>
</Card>
) : (
<div className="results-list">
{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} />
),
)}
</div>
)}
</main>
);
}
function SkillResultRow({ result }: { result: UnifiedSkillResult }) {
const skill = result.skill as unknown as PublicSkill;
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} />;
}
},
});
+1 -436
View File
@@ -1,28 +1,15 @@
import { createFileRoute } from "@tanstack/react-router";
import { useMutation, useQuery } from "convex/react";
import {
Eye,
Grid3X3,
LayoutGrid,
List,
Monitor,
Moon,
RotateCcw,
Settings2,
Sparkles,
Sun,
Zap,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import { Container } from "../components/layout/Container";
import { SignInButton } from "../components/SignInButton";
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { SignInButton } from "../components/SignInButton";
import {
Dialog,
DialogContent,
@@ -34,25 +21,9 @@ import {
} from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../components/ui/select";
import { Separator } from "../components/ui/separator";
import { Switch } from "../components/ui/switch";
import { Textarea } from "../components/ui/textarea";
import { gravatarUrl } from "../lib/gravatar";
import {
type AnimationLevel,
type CodeFontSize,
type LayoutDensity,
type ListViewMode,
usePreferences,
} from "../lib/preferences";
import { getThemeFamilyLabel, THEME_FAMILY_OPTIONS, useThemeMode } from "../lib/theme";
export const Route = createFileRoute("/settings")({
component: Settings,
@@ -62,16 +33,6 @@ export function Settings() {
const me = useQuery(api.users.me);
const updateProfile = useMutation(api.users.updateProfile);
const deleteAccount = useMutation(api.users.deleteAccount);
const {
customTheme,
family: themeFamily,
importCustomTheme,
mode: themeMode,
setFamily: setThemeFamily,
setMode: setThemeMode,
clearCustomTheme,
} = useThemeMode();
const { preferences, updatePreference, resetPreferences, isAdvancedMode } = usePreferences();
const tokens = useQuery(api.tokens.listMine, me ? {} : "skip") as
| Array<{
_id: Id<"apiTokens">;
@@ -108,7 +69,6 @@ export function Settings() {
const [memberHandle, setMemberHandle] = useState("");
const [memberRole, setMemberRole] = useState<"owner" | "admin" | "publisher">("publisher");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [customThemeInput, setCustomThemeInput] = useState("");
const orgs = (publisherMemberships ?? []).filter((entry) => entry.publisher.kind === "org");
const selectedOrg =
orgs.find((entry) => entry.publisher.handle === selectedOrgHandle) ?? orgs[0] ?? null;
@@ -191,20 +151,6 @@ export function Settings() {
}
}
async function onImportCustomTheme() {
try {
const importedTheme = await importCustomTheme(customThemeInput);
setCustomThemeInput("");
toast.success(`Imported ${importedTheme.name ?? "custom theme"}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to import theme");
}
}
function onRemoveCustomTheme() {
clearCustomTheme();
toast.success("Removed custom theme");
}
return (
<Container size="narrow" className="py-10">
<main className="flex flex-col gap-6">
@@ -262,387 +208,6 @@ export function Settings() {
</form>
</Card>
{/* Customization */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Settings2 size={18} />
Customization
</CardTitle>
<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)]">
Advanced
</Label>
<Switch
id="advanced-mode"
checked={isAdvancedMode}
onCheckedChange={(checked) => updatePreference("advancedMode", checked)}
/>
</div>
</div>
</CardHeader>
<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>
<div className="grid gap-3 sm:grid-cols-3">
{THEME_FAMILY_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setThemeFamily(option.value)}
className="rounded-[var(--r-md)] border p-4 text-left transition-colors"
style={{
borderColor:
themeFamily === option.value ? "var(--accent)" : "var(--border-ui)",
background:
themeFamily === option.value
? "var(--accent-subtle)"
: "var(--surface-muted)",
}}
>
<div className="text-sm font-semibold text-[color:var(--ink)]">{option.label}</div>
<div className="mt-1 text-xs text-[color:var(--ink-soft)]">
{option.description}
</div>
</button>
))}
</div>
<div className="flex flex-wrap gap-2">
<Button
variant={themeMode === "light" ? "primary" : "ghost"}
size="sm"
onClick={() => setThemeMode("light")}
className="flex items-center gap-2"
>
<Sun size={14} />
Light
</Button>
<Button
variant={themeMode === "dark" ? "primary" : "ghost"}
size="sm"
onClick={() => setThemeMode("dark")}
className="flex items-center gap-2"
>
<Moon size={14} />
Dark
</Button>
<Button
variant={themeMode === "system" ? "primary" : "ghost"}
size="sm"
onClick={() => setThemeMode("system")}
className="flex items-center gap-2"
>
<Monitor size={14} />
System
</Button>
</div>
<div className="space-y-3 rounded-[var(--r-md)] border border-[color:var(--border-ui)] bg-[color:var(--surface-muted)] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[color:var(--ink)]">
tweakcn overlay
</div>
<p className="text-xs text-[color:var(--ink-soft)]">
Paste a tweakcn URL, a tweakcn theme name, CSS variables, or JSON.
</p>
</div>
{customTheme ? (
<Badge variant="accent">
{customTheme.name} on {getThemeFamilyLabel(themeFamily)}
</Badge>
) : null}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={customThemeInput}
onChange={(event) => setCustomThemeInput(event.target.value)}
placeholder="https://tweakcn.com/... or midnight-ocean"
aria-label="Import tweakcn theme"
/>
<Button type="button" variant="primary" onClick={onImportCustomTheme}>
Import
</Button>
{customTheme ? (
<Button type="button" variant="ghost" onClick={onRemoveCustomTheme}>
<RotateCcw size={14} />
Remove
</Button>
) : null}
</div>
{customTheme ? (
<div className="text-xs text-[color:var(--ink-soft)]">
Source: {customTheme.source}
</div>
) : null}
</div>
</div>
<Separator />
{/* 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)]">
Density
</Label>
<Select
value={preferences.layoutDensity}
onValueChange={(value) => updatePreference("layoutDensity", value as LayoutDensity)}
>
<SelectTrigger id="layout-density">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="comfortable">
<span className="flex items-center gap-2">
<LayoutGrid size={14} />
Comfortable
</span>
</SelectItem>
<SelectItem value="compact">
<span className="flex items-center gap-2">
<Grid3X3 size={14} />
Compact
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="list-view" className="text-xs text-[color:var(--ink-soft)]">
Default view
</Label>
<Select
value={preferences.listViewMode}
onValueChange={(value) => updatePreference("listViewMode", value as ListViewMode)}
>
<SelectTrigger id="list-view">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="grid">
<span className="flex items-center gap-2">
<LayoutGrid size={14} />
Grid
</span>
</SelectItem>
<SelectItem value="list">
<span className="flex items-center gap-2">
<List size={14} />
List
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="show-descriptions" className="text-sm">Show descriptions</Label>
<Switch
id="show-descriptions"
checked={preferences.showDescriptions}
onCheckedChange={(checked) => updatePreference("showDescriptions", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="show-stats" className="text-sm">Show statistics</Label>
<Switch
id="show-stats"
checked={preferences.showStats}
onCheckedChange={(checked) => updatePreference("showStats", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="show-tags" className="text-sm">Show tags</Label>
<Switch
id="show-tags"
checked={preferences.showTags}
onCheckedChange={(checked) => updatePreference("showTags", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="sticky-header" className="text-sm">Sticky header</Label>
<Switch
id="sticky-header"
checked={preferences.stickyHeader}
onCheckedChange={(checked) => updatePreference("stickyHeader", checked)}
/>
</div>
</div>
</div>
{isAdvancedMode ? (
<>
<Separator />
{/* Code & Content Section - Advanced */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
<Sparkles size={14} className="text-[color:var(--accent)]" />
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)]">
Code font size
</Label>
<Select
value={preferences.codeFontSize}
onValueChange={(value) => updatePreference("codeFontSize", value as CodeFontSize)}
>
<SelectTrigger id="code-font-size">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="small">Small (12px)</SelectItem>
<SelectItem value="medium">Medium (14px)</SelectItem>
<SelectItem value="large">Large (16px)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<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)}
>
<SelectTrigger id="animation-level">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full">
<span className="flex items-center gap-2">
<Zap size={14} />
Full
</span>
</SelectItem>
<SelectItem value="reduced">Reduced</SelectItem>
<SelectItem value="none">None</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="line-numbers" className="text-sm">Line numbers in code</Label>
<Switch
id="line-numbers"
checked={preferences.lineNumbers}
onCheckedChange={(checked) => updatePreference("lineNumbers", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="word-wrap" className="text-sm">Word wrap in code</Label>
<Switch
id="word-wrap"
checked={preferences.wordWrap}
onCheckedChange={(checked) => updatePreference("wordWrap", checked)}
/>
</div>
</div>
</div>
<Separator />
{/* Accessibility Section - Advanced */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
<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>
<p className="text-xs text-[color:var(--ink-soft)]">Minimize animations</p>
</div>
<Switch
id="reduced-motion"
checked={preferences.reducedMotion}
onCheckedChange={(checked) => updatePreference("reducedMotion", checked)}
/>
</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>
</div>
<Switch
id="high-contrast"
checked={preferences.highContrast}
onCheckedChange={(checked) => updatePreference("highContrast", checked)}
/>
</div>
</div>
</div>
<Separator />
{/* Experimental Features - Advanced */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
<Sparkles size={14} className="text-[color:var(--gold)]" />
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>
</div>
<Switch
id="experimental-features"
checked={preferences.experimentalFeatures}
onCheckedChange={(checked) => updatePreference("experimentalFeatures", checked)}
/>
</div>
</div>
</>
) : null}
<Separator />
{/* Reset Section */}
<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>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
resetPreferences();
toast.success("Preferences reset to defaults");
}}
className="flex items-center gap-2"
>
<RotateCcw size={14} />
Reset
</Button>
</div>
</CardContent>
</Card>
{/* Organizations */}
<Card>
<CardHeader>
+82 -43
View File
@@ -1,8 +1,12 @@
import { Link } from "@tanstack/react-router";
import { Loader2 } from "lucide-react";
import type { RefObject } from "react";
import { EmptyState } from "../../components/EmptyState";
import { SkillCardSkeletonGrid } from "../../components/skeletons/SkillCardSkeleton";
import { SkillCard } from "../../components/SkillCard";
import { SkillListItem } from "../../components/SkillListItem";
import { getPlatformLabels } from "../../components/skillDetailUtils";
import { SkillStatsTripletLine } from "../../components/SkillStats";
import { SkillMetricsRow, SkillStatsTripletLine } from "../../components/SkillStats";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { UserBadge } from "../../components/UserBadge";
import { getSkillBadges } from "../../lib/badges";
@@ -25,7 +29,7 @@ export function SkillsResults({
isLoadingSkills,
sorted,
view,
listDoneLoading: _listDoneLoading,
listDoneLoading,
hasQuery,
canLoadMore,
isLoadingMore,
@@ -36,27 +40,14 @@ export function SkillsResults({
return (
<>
{isLoadingSkills ? (
<div className="skeleton-list">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="skeleton-row">
<div className="skeleton-icon" />
<div className="skeleton-row-body">
<div className="skeleton-bar skeleton-bar-lg" />
<div className="skeleton-bar skeleton-bar-sm" />
<div className="skeleton-bar skeleton-bar-xs" />
</div>
</div>
))}
</div>
<SkillCardSkeletonGrid count={6} />
) : sorted.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">No skills found</p>
<p className="empty-state-body">
{hasQuery ? "Try a different search term or remove filters." : "No skills have been published yet."}
</p>
</div>
<EmptyState
title={listDoneLoading || hasQuery ? "No skills match that filter" : "Loading skills..."}
description={hasQuery ? "Try adjusting your search or filters." : undefined}
/>
) : view === "cards" ? (
<div className="grid">
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
{sorted.map((entry) => {
const skill = entry.skill;
const clawdis = entry.latestVersion?.parsed?.clawdis;
@@ -74,57 +65,105 @@ export function SkillsResults({
platformLabels={platforms.length ? platforms : undefined}
summaryFallback="Agent-ready skill pack."
meta={
<div className="skill-card-footer-rows">
<>
<UserBadge
user={entry.owner}
fallbackHandle={ownerHandle}
prefix="by"
link={false}
/>
<div className="stat">
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
<SkillStatsTripletLine stats={skill.stats} />
</div>
</div>
</span>
</>
}
/>
);
})}
</div>
) : (
<div className="results-list">
{sorted.map((entry) => {
/* List view */
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)]">
{/* Table header */}
<div className="grid grid-cols-[minmax(160px,1.2fr)_minmax(120px,1.6fr)_minmax(100px,0.8fr)_minmax(120px,1fr)] gap-4 border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] px-5 py-3 text-xs font-bold uppercase tracking-wider text-[color:var(--ink-soft)]">
<span>Skill</span>
<span>Summary</span>
<span>Author</span>
<span className="text-right">Stats</span>
</div>
{sorted.map((entry, i) => {
const skill = entry.skill;
const ownerHandle = entry.owner?.handle ?? entry.ownerHandle ?? null;
const skillHref = buildSkillHref(skill, ownerHandle);
return (
<SkillListItem
<Link
key={skill._id}
skill={skill}
ownerHandle={ownerHandle}
owner={entry.owner}
/>
className={`grid grid-cols-[minmax(160px,1.2fr)_minmax(120px,1.6fr)_minmax(100px,0.8fr)_minmax(120px,1fr)] items-center gap-4 px-5 py-3.5 no-underline transition-colors hover:bg-[color:var(--surface-muted)] ${
i % 2 === 0 ? "bg-[color:var(--surface)]" : "bg-[color:var(--bg-soft)]"
}`}
to={skillHref}
>
<span className="flex flex-col gap-1">
<span className="flex items-center gap-2">
<span className="font-semibold text-[color:var(--ink)]">
{skill.displayName}
</span>
{getSkillBadges(skill).map((badge) => (
<Badge key={badge} variant="compact">
{badge}
</Badge>
))}
</span>
{entry.latestVersion?.version ? (
<span className="font-mono text-xs text-[color:var(--ink-soft)]">
v{entry.latestVersion.version}
</span>
) : null}
</span>
<span className="truncate text-sm text-[color:var(--ink-soft)]">
{skill.summary ?? "No summary provided."}
</span>
<span className="text-sm">
<UserBadge
user={entry.owner}
fallbackHandle={ownerHandle}
prefix=""
link={false}
/>
</span>
<span className="flex flex-wrap justify-end gap-3 text-xs text-[color:var(--ink-soft)]">
<SkillMetricsRow stats={skill.stats} />
</span>
</Link>
);
})}
</div>
)}
{canLoadMore || isLoadingMore ? (
<div
ref={canAutoLoad ? loadMoreRef : null}
className="card mt-4 flex justify-center"
>
{/* Load more */}
{(canLoadMore || isLoadingMore) && (
<div ref={canAutoLoad ? loadMoreRef : null} className="flex justify-center pt-4">
{canAutoLoad ? (
isLoadingMore ? (
"Loading more..."
<div className="flex items-center gap-2 text-sm font-medium text-[color:var(--ink-soft)]">
<Loader2 className="h-4 w-4 animate-spin" />
Loading more...
</div>
) : (
"Scroll to load more"
<div className="text-sm text-[color:var(--ink-soft)]">Scroll to load more</div>
)
) : (
<Button type="button" onClick={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? "Loading..." : "Load more"}
<Button
variant="outline"
onClick={loadMore}
disabled={isLoadingMore}
loading={isLoadingMore}
>
Load more
</Button>
)}
</div>
) : null}
)}
</>
);
}
+5 -5
View File
@@ -219,7 +219,7 @@ export function SkillsToolbar({
onClick={view === "list" ? onToggleView : undefined}
className={`inline-flex h-[30px] w-[30px] items-center justify-center rounded-full transition-colors ${
view === "cards"
? "bg-accent text-accent-fg"
? "bg-[color:var(--accent)] text-white"
: "text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
aria-label="Grid view"
@@ -231,7 +231,7 @@ export function SkillsToolbar({
onClick={view === "cards" ? onToggleView : undefined}
className={`inline-flex h-[30px] w-[30px] items-center justify-center rounded-full transition-colors ${
view === "list"
? "bg-accent text-accent-fg"
? "bg-[color:var(--accent)] text-white"
: "text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
aria-label="List view"
@@ -260,10 +260,10 @@ function FilterChip({
type="button"
aria-pressed={active}
onClick={onClick}
className={`inline-flex items-center gap-1.5 rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 min-h-[36px] text-xs font-semibold transition-all duration-150 ${
className={`inline-flex min-h-[36px] items-center gap-1.5 rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 text-xs font-semibold transition-all duration-150 dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)] ${
active
? "border-[color:var(--accent)]/30 bg-[color:var(--accent)]/10 text-[color:var(--accent)]"
: "text-[color:var(--ink-soft)] hover:border-[color:var(--border-ui-hover)] hover:text-[color:var(--ink)] dark:text-[rgba(245,238,232,0.88)] dark:hover:text-[rgba(245,238,232,0.96)]"
? "border-[color:var(--accent)]/30 bg-[color:var(--accent)]/10 text-[color:var(--accent)] dark:border-[rgba(255,131,95,0.34)] dark:bg-[rgba(255,131,95,0.14)] dark:text-[#ffd5c9]"
: "text-[color:var(--ink-soft)] hover:border-[color:var(--border-ui-hover)] hover:text-[color:var(--ink)] dark:text-[rgba(245,238,232,0.88)] dark:hover:border-[rgba(255,255,255,0.2)] dark:hover:text-[rgba(245,238,232,0.96)]"
}`}
>
{active && !icon && <Check className="h-3 w-3" />}
+64 -128
View File
@@ -1,23 +1,15 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { Search } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useRef } from "react";
import { api } from "../../../convex/_generated/api";
import { BrowseSidebar } from "../../components/BrowseSidebar";
import { SKILL_CATEGORIES } from "../../lib/categories";
import { formatCompactStat } from "../../lib/numberFormat";
import { SKILL_CAPABILITY_TAGS } from "../../../convex/lib/skillCapabilityTags";
import { Container } from "../../components/layout/Container";
import { parseSort } from "./-params";
import { SkillsResults } from "./-SkillsResults";
import { SkillsToolbar } from "./-SkillsToolbar";
import { useSkillsBrowseModel, type SkillsSearchState } from "./-useSkillsBrowseModel";
const SORT_OPTIONS = [
{ value: "downloads", label: "Most downloaded" },
{ value: "stars", label: "Most starred" },
{ value: "installs", label: "Most installed" },
{ value: "updated", label: "Recently updated" },
{ value: "newest", label: "Newest" },
{ value: "name", label: "Name" },
];
const SKILL_CAPABILITY_TAG_SET = new Set<string>(SKILL_CAPABILITY_TAGS);
export const Route = createFileRoute("/skills/")({
validateSearch: (search): SkillsSearchState => {
@@ -35,6 +27,10 @@ export const Route = createFileRoute("/skills/")({
search.nonSuspicious === true
? true
: undefined,
tag:
typeof search.tag === "string" && SKILL_CAPABILITY_TAG_SET.has(search.tag)
? search.tag
: undefined,
view: search.view === "cards" || search.view === "list" ? search.view : undefined,
focus: search.focus === "search" ? "search" : undefined,
};
@@ -50,6 +46,7 @@ export const Route = createFileRoute("/skills/")({
dir: search.dir || undefined,
highlighted: search.highlighted || undefined,
nonSuspicious: search.nonSuspicious || undefined,
tag: search.tag || undefined,
view: search.view || undefined,
focus: search.focus || undefined,
},
@@ -65,8 +62,7 @@ export function SkillsIndex() {
const searchInputRef = useRef<HTMLInputElement>(null);
const totalSkills = useQuery(api.skills.countPublicSkills);
const totalSkillsText =
typeof totalSkills === "number" ? formatCompactStat(totalSkills) : null;
const [sidebarOpen, setSidebarOpen] = useState(false);
typeof totalSkills === "number" ? totalSkills.toLocaleString("en-US") : null;
const model = useSkillsBrowseModel({
navigate,
@@ -74,120 +70,60 @@ export function SkillsIndex() {
searchInputRef,
});
const sortOptionsWithRelevance = model.hasQuery
? [{ value: "relevance", label: "Relevance" }, ...SORT_OPTIONS]
: SORT_OPTIONS;
const handleFilterToggle = useCallback(
(key: string) => {
if (key === "highlighted") model.onToggleHighlighted();
else if (key === "nonSuspicious") model.onToggleNonSuspicious();
},
[model.onToggleHighlighted, model.onToggleNonSuspicious],
);
const handleCategoryChange = useCallback(
(slug: string | undefined) => {
if (slug) {
const cat = SKILL_CATEGORIES.find((c) => c.slug === slug);
if (cat?.keywords[0]) {
model.onQueryChange(cat.keywords[0]);
}
} else {
model.onQueryChange("");
}
},
[model.onQueryChange],
);
const activeCategory = useMemo(() => {
if (!model.query) return undefined;
return (
SKILL_CATEGORIES.find((c) =>
c.keywords.some((k) => k === model.query.trim().toLowerCase()),
)?.slug ?? undefined
);
}, [model.query]);
return (
<main className="browse-page">
<div className="browse-page-header">
<button
className="browse-sidebar-toggle"
type="button"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label="Toggle filters"
>
Filters
</button>
<h1 className="browse-title">
Skills
{totalSkillsText ? (
<span className="browse-count">{totalSkillsText}</span>
) : null}
</h1>
</div>
<div className="browse-page-search">
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
<input
ref={searchInputRef}
className="browse-search-input"
value={model.query}
onChange={(event) => model.onQueryChange(event.target.value)}
placeholder="Search skills..."
/>
</div>
<div className={`browse-layout${sidebarOpen ? " sidebar-open" : ""}`}>
<BrowseSidebar
categories={SKILL_CATEGORIES}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
sortOptions={sortOptionsWithRelevance}
activeSort={model.sort}
onSortChange={model.onSortChange}
filters={[
{ key: "highlighted", label: "Staff picks", active: model.highlightedOnly },
{ key: "nonSuspicious", label: "Hide suspicious", active: model.nonSuspiciousOnly },
]}
onFilterToggle={handleFilterToggle}
/>
<div className="browse-results">
<div className="browse-results-toolbar">
<span className="browse-results-count">
<main className="py-10">
<Container size="wide">
<div className="flex flex-col gap-6">
{/* Header */}
<header>
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)]">
Skills
<span className="ml-2 text-lg font-normal text-[color:var(--ink-soft)] opacity-70">
({model.hasQuery || model.highlightedOnly || model.nonSuspiciousOnly
? model.sorted.length.toLocaleString("en-US")
: totalSkillsText ?? "…"})
</span>
</h1>
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
{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();
}}
>
Clear
</button>
) : null}
</span>
<div className="browse-view-toggle">
<button
className={`browse-view-btn${model.view === "list" ? " is-active" : ""}`}
type="button"
onClick={model.view === "cards" ? model.onToggleView : undefined}
>
List
</button>
<button
className={`browse-view-btn${model.view === "cards" ? " is-active" : ""}`}
type="button"
onClick={model.view === "list" ? model.onToggleView : undefined}
>
Cards
</button>
</div>
</div>
? "Loading skills..."
: `Browse the skill library${model.activeFilters.length ? ` (${model.activeFilters.join(", ")})` : ""}.`}
</p>
</header>
{/* Toolbar */}
<SkillsToolbar
searchInputRef={searchInputRef}
query={model.query}
hasQuery={model.hasQuery}
sort={model.sort}
dir={model.dir}
view={model.view}
highlightedOnly={model.highlightedOnly}
nonSuspiciousOnly={model.nonSuspiciousOnly}
capabilityTag={model.capabilityTag}
onQueryChange={model.onQueryChange}
onToggleHighlighted={model.onToggleHighlighted}
onToggleNonSuspicious={model.onToggleNonSuspicious}
onCapabilityTagChange={model.onCapabilityTagChange}
onSortChange={model.onSortChange}
onToggleDir={model.onToggleDir}
onToggleView={model.onToggleView}
/>
{/* Results count */}
{model.sorted.length > 0 && (
<p className="text-xs font-medium text-[color:var(--ink-soft)]">
{model.sorted.length}
{!model.hasQuery && totalSkillsText ? ` of ${totalSkillsText}` : ""} skills
{model.hasQuery ? ` matching "${model.query}"` : ""}
{model.highlightedOnly || model.nonSuspiciousOnly || model.capabilityTag
? ` (filtered)`
: ""}
</p>
)}
{/* Results */}
<SkillsResults
isLoadingSkills={model.isLoadingSkills}
sorted={model.sorted}
@@ -201,7 +137,7 @@ export function SkillsIndex() {
loadMore={model.loadMore}
/>
</div>
</div>
</Container>
</main>
);
}
+231 -52
View File
@@ -1,15 +1,32 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { Badge } from "../../components/ui/badge";
import { useAction, useQuery } from "convex/react";
import { Search } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { api } from "../../../convex/_generated/api";
import { EmptyState } from "../../components/EmptyState";
import { Container } from "../../components/layout/Container";
import { SkillCardSkeletonGrid } from "../../components/skeletons/SkillCardSkeleton";
import { SoulCard } from "../../components/SoulCard";
import { SoulMetricsRow, SoulStatsTripletLine } from "../../components/SoulStats";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import type { PublicSoul } from "../../lib/publicUser";
const sortKeys = ["newest", "downloads", "stars", "name", "updated"] as const;
type SortKey = (typeof sortKeys)[number];
type SortDir = "asc" | "desc";
function parseSort(value: unknown): SortKey {
if (typeof value !== "string") return "newest";
if ((sortKeys as readonly string[]).includes(value)) return value as SortKey;
return "newest";
}
function parseDir(value: unknown, sort: SortKey): SortDir {
if (value === "asc" || value === "desc") return value;
return sort === "name" ? "asc" : "desc";
}
export const Route = createFileRoute("/souls/")({
validateSearch: (search) => {
return {
@@ -20,61 +37,223 @@ export const Route = createFileRoute("/souls/")({
focus: search.focus === "search" ? "search" : undefined,
};
},
component: SoulsHoldingPage,
component: SoulsIndex,
});
function SoulsHoldingPage() {
return (
<main className="browse-page souls-coming-page">
<section className="souls-coming-hero">
<div>
<div className="skill-card-tags mb-3">
<Badge>Souls</Badge>
<Badge variant="accent">Coming soon</Badge>
</div>
<h1 className="about-title">SOUL.md discovery is on deck</h1>
<p className="about-lead">
This page is the holding area for public SOUL.md profiles youll be able to discover,
compare, and share. Were not shipping a half-baked directory just to tick a box.
</p>
</div>
<div className="souls-coming-grid">
<article className="about-rule-card">
<h2>Discover</h2>
<p>Browse public system personas, writing voices, and full character sheets.</p>
</article>
<article className="about-rule-card">
<h2>Share</h2>
<p>Publish versioned SOUL.md files with attribution and clean history.</p>
</article>
<article className="about-rule-card">
<h2>Compare</h2>
<p>Inspect changes, stats, and adoption without the usual metadata sludge.</p>
</article>
</div>
</section>
function SoulsIndex() {
const navigate = Route.useNavigate();
const search = Route.useSearch();
const sort = search.sort ?? "newest";
const dir = parseDir(search.dir, sort);
const view = search.view ?? "list";
const [query, setQuery] = useState(search.q ?? "");
<section className="about-enforcement">
<div>
<span className="about-callout-label">In the meantime</span>
<p className="about-lead mb-0">
ClawHub already handles skills and plugins. Souls will get the same discovery treatment
once the publishing flow is ready.
</p>
const souls = useQuery(api.souls.list, { limit: 500 }) as PublicSoul[] | undefined;
const ensureSoulSeeds = useAction(api.seed.ensureSoulSeeds);
const seedEnsuredRef = useRef(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const isLoadingSouls = souls === undefined;
useEffect(() => {
setQuery(search.q ?? "");
}, [search.q]);
// Auto-focus search input when focus=search param is present
useEffect(() => {
if (search.focus === "search" && searchInputRef.current) {
searchInputRef.current.focus();
// Clear the focus param from URL to avoid re-focusing on navigation
void navigate({ search: (prev) => ({ ...prev, focus: undefined }), replace: true });
}
}, [search.focus, navigate]);
useEffect(() => {
if (seedEnsuredRef.current) return;
seedEnsuredRef.current = true;
void ensureSoulSeeds({});
}, [ensureSoulSeeds]);
const filtered = useMemo(() => {
const value = query.trim().toLowerCase();
const all = souls ?? [];
if (!value) return all;
return all.filter((soul) => {
if (soul.slug.toLowerCase().includes(value)) return true;
if (soul.displayName.toLowerCase().includes(value)) return true;
return (soul.summary ?? "").toLowerCase().includes(value);
});
}, [query, souls]);
const sorted = useMemo(() => {
const multiplier = dir === "asc" ? 1 : -1;
const results = [...filtered];
results.sort((a, b) => {
switch (sort) {
case "downloads":
return (a.stats.downloads - b.stats.downloads) * multiplier;
case "stars":
return (a.stats.stars - b.stats.stars) * multiplier;
case "updated":
return (a.updatedAt - b.updatedAt) * multiplier;
case "name":
return (
(a.displayName.localeCompare(b.displayName) || a.slug.localeCompare(b.slug)) *
multiplier
);
default:
return (a.createdAt - b.createdAt) * multiplier;
}
});
return results;
}, [dir, filtered, sort]);
const showing = sorted.length;
const total = souls?.length;
return (
<main className="py-10">
<Container>
<header className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)] mb-2">Souls</h1>
<p className="text-sm text-[color:var(--ink-soft)]">
{isLoadingSouls
? "Loading souls..."
: `${showing}${typeof total === "number" ? ` of ${total}` : ""} souls.`}
</p>
</div>
<div className="flex flex-col gap-3 sm:items-end">
<div className="relative flex items-center">
<Search
className="pointer-events-none absolute left-3 h-4 w-4 text-[color:var(--ink-soft)] opacity-50"
aria-hidden="true"
/>
<Input
ref={searchInputRef}
className="pl-9"
value={query}
onChange={(event) => {
const next = event.target.value;
const trimmed = next.trim();
setQuery(next);
void navigate({
search: (prev) => ({ ...prev, q: trimmed ? next : undefined }),
replace: true,
});
}}
placeholder="Filter by name, slug, or summary..."
/>
</div>
<div className="flex items-center gap-2">
<select
className="min-h-[34px] rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3 py-1.5 text-sm text-[color:var(--ink)] dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
value={sort}
onChange={(event) => {
const nextSort = parseSort(event.target.value);
void navigate({
search: (prev) => ({
...prev,
sort: nextSort,
dir: parseDir(prev.dir, nextSort),
}),
replace: true,
});
}}
aria-label="Sort souls"
>
<option value="newest">Newest</option>
<option value="updated">Recently updated</option>
<option value="downloads">Downloads</option>
<option value="stars">Stars</option>
<option value="name">Name</option>
</select>
<Button
variant="ghost"
size="sm"
aria-label={`Sort direction ${dir}`}
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
dir: parseDir(prev.dir, sort) === "asc" ? "desc" : "asc",
}),
replace: true,
});
}}
>
{dir === "asc" ? "\u2191" : "\u2193"}
</Button>
<Button
variant={view === "cards" ? "primary" : "ghost"}
size="sm"
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
view: prev.view === "cards" ? undefined : "cards",
}),
replace: true,
});
}}
>
{view === "cards" ? "List" : "Cards"}
</Button>
</div>
</div>
</header>
<div className="mt-6">
{isLoadingSouls ? (
<SkillCardSkeletonGrid count={6} />
) : showing === 0 ? (
<EmptyState
title="No souls match that filter"
description="Try a different search term."
/>
) : view === "cards" ? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
{sorted.map((soul) => (
<SoulCard
key={soul._id}
soul={soul}
summaryFallback="A SOUL.md bundle."
meta={
<div className="text-sm text-[color:var(--ink-soft)]">
<SoulStatsTripletLine stats={soul.stats} />
</div>
}
/>
))}
</div>
) : (
<div className="flex flex-col gap-2">
{sorted.map((soul) => (
<Link
key={soul._id}
className="flex items-center justify-between gap-4 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-4 transition-all duration-200 hover:-translate-y-px hover:shadow-[0_10px_20px_rgba(29,26,23,0.12)]"
to="/souls/$slug"
params={{ slug: soul.slug }}
>
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-display font-bold text-[color:var(--ink)]">
{soul.displayName}
</span>
<span className="text-sm text-[color:var(--ink-soft)]">/{soul.slug}</span>
</div>
<div className="text-sm text-[color:var(--ink-soft)]">
{soul.summary ?? "SOUL.md bundle."}
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
<SoulMetricsRow stats={soul.stats} />
</div>
</Link>
))}
</div>
)}
</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 }}>
Browse Skills
</Link>
</Button>
<Button asChild>
<Link to="/users" search={{ q: undefined }}>
Browse Users
</Link>
</Button>
</div>
</section>
</Container>
</main>
);
}
+7 -6
View File
@@ -5,6 +5,7 @@ import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Doc } from "../../convex/_generated/dataModel";
import { EmptyState } from "../components/EmptyState";
import { Container } from "../components/layout/Container";
import { SignInButton } from "../components/SignInButton";
import { Button } from "../components/ui/button";
import { formatCompactStat } from "../lib/numberFormat";
@@ -25,8 +26,8 @@ function Stars() {
if (!me) {
return (
<main className="browse-page">
<div className="browse-page-narrow">
<main className="py-10">
<Container size="narrow">
<EmptyState
icon={Star}
title="Sign in to see your highlights"
@@ -34,14 +35,14 @@ function Stars() {
>
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
</EmptyState>
</div>
</Container>
</main>
);
}
return (
<main className="browse-page">
<div className="browse-page-narrow">
<main className="py-10">
<Container size="narrow">
<div className="flex flex-col gap-6">
<header>
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)]">
@@ -104,7 +105,7 @@ function Stars() {
</div>
)}
</div>
</div>
</Container>
</main>
);
}
+210 -136
View File
@@ -1,12 +1,23 @@
import { createFileRoute } from "@tanstack/react-router";
import { useMutation, useQuery } from "convex/react";
import { Star, User } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { api } from "../../../convex/_generated/api";
import type { Doc } from "../../../convex/_generated/dataModel";
import { SkillListItem } from "../../components/SkillListItem";
import { EmptyState } from "../../components/EmptyState";
import { Container } from "../../components/layout/Container";
import { SignInButton } from "../../components/SignInButton";
import { SkillCardSkeletonGrid } from "../../components/skeletons/SkillCardSkeleton";
import { SkillCard } from "../../components/SkillCard";
import { SkillStatsTripletLine } from "../../components/SkillStats";
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { Skeleton } from "../../components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs";
import { getSkillBadges } from "../../lib/badges";
import type { PublicSkill, PublicUser } from "../../lib/publicUser";
export const Route = createFileRoute("/u/$handle")({
@@ -40,18 +51,28 @@ function UserProfile() {
if (user === undefined) {
return (
<main className="section">
<Card>
<div className="loading-indicator">Loading user</div>
</Card>
<main className="py-10">
<Container size="narrow">
<div className="flex flex-col items-center gap-4">
<Skeleton className="h-20 w-20 rounded-full" />
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-24" />
</div>
</Container>
</main>
);
}
if (user === null) {
return (
<main className="section">
<Card>User not found.</Card>
<main className="py-10">
<Container size="narrow">
<EmptyState
icon={User}
title="User not found"
description="This user doesn't exist or their account has been removed."
/>
</Container>
</main>
);
}
@@ -66,93 +87,134 @@ function UserProfile() {
const published = publishedSkills ?? [];
return (
<main className="browse-page">
<div className="profile-header">
<div className="profile-avatar-lg" aria-hidden="true">
{avatar ? <img src={avatar} alt="" /> : <span>{initial}</span>}
</div>
<div className="profile-info">
<h1 className="profile-display-name">{displayName}</h1>
<span className="profile-handle">@{displayHandle}</span>
<div className="profile-stats-row">
<span className="profile-stat">{published.length} published</span>
<span className="profile-stat">{skills.length} stars</span>
</div>
</div>
</div>
{isSelf ? (
<div className="profile-tabs" role="tablist" aria-label="Profile tabs">
<button
className={tab === "stars" ? "profile-tab is-active" : "profile-tab"}
type="button"
role="tab"
aria-selected={tab === "stars"}
onClick={() => setTab("stars")}
>
Overview
</button>
<button
className={tab === "installed" ? "profile-tab is-active" : "profile-tab"}
type="button"
role="tab"
aria-selected={tab === "installed"}
onClick={() => setTab("installed")}
>
Installed
</button>
</div>
) : null}
{tab === "installed" && isSelf ? (
<InstalledSection
includeRemoved={includeRemoved}
onToggleRemoved={() => setIncludeRemoved((value) => !value)}
data={installed}
/>
) : (
<>
{published.length > 0 ? (
<>
<h2 className="home-section-title mb-3">
Published ({published.length})
</h2>
{isLoadingPublished ? (
<Card>
<div className="loading-indicator">Loading published skills...</div>
</Card>
) : (
<div className="results-list mb-6">
{published.map((skill) => (
<SkillListItem key={skill._id} skill={skill} />
))}
</div>
)}
</>
) : null}
<h2 className="home-section-title mb-3">
Stars ({skills.length})
</h2>
{isLoadingSkills ? (
<Card>
<div className="loading-indicator">Loading stars...</div>
</Card>
) : skills.length === 0 ? (
<Card>No stars yet.</Card>
) : (
<div className="results-list">
{skills.map((skill) => (
<SkillListItem key={skill._id} skill={skill} />
))}
<main className="py-10">
<Container size="narrow">
<div className="flex flex-col gap-8">
{/* Profile header */}
<Card className="flex-row items-center gap-4 p-6">
<Avatar className="h-16 w-16">
{avatar && <AvatarImage src={avatar} alt={displayName} />}
<AvatarFallback className="text-xl">{initial}</AvatarFallback>
</Avatar>
<div>
<h1 className="font-display text-xl font-bold text-[color:var(--ink)]">
{displayName}
</h1>
<p className="font-mono text-sm text-[color:var(--ink-soft)]">@{displayHandle}</p>
</div>
</Card>
{/* Tabs */}
{isSelf ? (
<Tabs value={tab} onValueChange={(v) => setTab(v as "stars" | "installed")}>
<TabsList>
<TabsTrigger value="stars">Stars</TabsTrigger>
<TabsTrigger value="installed">Installed</TabsTrigger>
</TabsList>
<TabsContent value="stars" className="mt-6">
<PublishedAndStarred
published={published}
isLoadingPublished={isLoadingPublished}
skills={skills}
isLoadingSkills={isLoadingSkills}
/>
</TabsContent>
<TabsContent value="installed" className="mt-6">
<InstalledSection
includeRemoved={includeRemoved}
onToggleRemoved={() => setIncludeRemoved((value) => !value)}
data={installed}
/>
</TabsContent>
</Tabs>
) : (
<PublishedAndStarred
published={published}
isLoadingPublished={isLoadingPublished}
skills={skills}
isLoadingSkills={isLoadingSkills}
/>
)}
</>
)}
</div>
</Container>
</main>
);
}
function PublishedAndStarred({
published,
isLoadingPublished,
skills,
isLoadingSkills,
}: {
published: PublicSkill[];
isLoadingPublished: boolean;
skills: PublicSkill[];
isLoadingSkills: boolean;
}) {
return (
<div className="flex flex-col gap-8">
{/* Published */}
<section>
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Published</h2>
<p className="mt-1 mb-4 text-sm text-[color:var(--ink-soft)]">
Skills published by this user.
</p>
{isLoadingPublished ? (
<SkillCardSkeletonGrid count={3} />
) : published.length > 0 ? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
{published.map((skill) => (
<SkillCard
key={skill._id}
skill={skill}
badge={getSkillBadges(skill)}
summaryFallback="Agent-ready skill pack."
meta={
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
<SkillStatsTripletLine stats={skill.stats} />
</span>
}
/>
))}
</div>
) : (
<p className="text-sm text-[color:var(--ink-soft)]">No published skills yet.</p>
)}
</section>
{/* Stars */}
<section>
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Stars</h2>
<p className="mt-1 mb-4 text-sm text-[color:var(--ink-soft)]">
Skills this user has starred.
</p>
{isLoadingSkills ? (
<SkillCardSkeletonGrid count={3} />
) : skills.length === 0 ? (
<EmptyState icon={Star} title="No stars yet" />
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
{skills.map((skill) => (
<SkillCard
key={skill._id}
skill={skill}
badge={getSkillBadges(skill)}
summaryFallback="Agent-ready skill pack."
meta={
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
<SkillStatsTripletLine stats={skill.stats} />
</span>
}
/>
))}
</div>
)}
</section>
</div>
);
}
function InstalledSection(props: {
includeRemoved: boolean;
onToggleRemoved: () => void;
@@ -161,51 +223,51 @@ function InstalledSection(props: {
const clearTelemetry = useMutation(api.telemetry.clearMyTelemetry);
const [showRaw, setShowRaw] = useState(false);
const data = props.data;
if (data === undefined) {
return (
<>
<h2 className="section-title text-xl">
Installed
</h2>
<Card>
<div className="loading-indicator">Loading telemetry</div>
</Card>
</>
<div className="flex flex-col gap-4">
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Installed</h2>
<SkillCardSkeletonGrid count={3} />
</div>
);
}
if (data === null) {
return (
<>
<h2 className="section-title text-xl">
Installed
</h2>
<Card>Sign in to view your installed skills.</Card>
</>
<div className="flex flex-col gap-4">
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Installed</h2>
<EmptyState title="Sign in to view your installed skills">
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
</EmptyState>
</div>
);
}
return (
<>
<h2 className="section-title text-xl">
Installed
</h2>
<p className="section-subtitle max-w-[760px]">
<div className="flex flex-col gap-4">
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Installed</h2>
<p className="max-w-2xl text-sm text-[color:var(--ink-soft)]">
Private view. Only you can see your folders/roots. Everyone else only sees aggregated
install counts per skill.
</p>
<div className="profile-actions">
<Button type="button" onClick={props.onToggleRemoved}>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={props.onToggleRemoved}>
{props.includeRemoved ? "Hide removed" : "Show removed"}
</Button>
<Button type="button" onClick={() => setShowRaw((value) => !value)}>
<Button variant="outline" size="sm" onClick={() => setShowRaw((value) => !value)}>
{showRaw ? "Hide JSON" : "Show JSON"}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => {
if (!window.confirm("Delete all telemetry data?")) return;
void clearTelemetry();
toast("Delete all telemetry data?", {
action: {
label: "Delete",
onClick: () => void clearTelemetry(),
},
});
}}
>
Delete telemetry
@@ -213,47 +275,59 @@ function InstalledSection(props: {
</div>
{showRaw ? (
<Card className="telemetry-json mb-4">
<pre className="mono m-0 whitespace-pre-wrap">
<Card className="mb-4">
<pre className="font-mono text-xs whitespace-pre-wrap break-all">
{JSON.stringify(data, null, 2)}
</pre>
</Card>
) : null}
{data.roots.length === 0 ? (
<Card>No telemetry yet. Run `clawhub sync` from the CLI.</Card>
<EmptyState
title="No telemetry yet"
description='Run "clawhub sync" from the CLI to start tracking.'
/>
) : (
<div className="grid gap-4">
{data.roots.map((root) => (
<Card key={root.rootId} className="telemetry-root">
<div className="telemetry-root-header">
<Card key={root.rootId}>
<div className="flex items-center justify-between">
<div>
<div className="telemetry-root-title">{root.label}</div>
<div className="telemetry-root-meta">
<h3 className="font-display text-sm font-bold text-[color:var(--ink)]">
{root.label}
</h3>
<p className="text-xs text-[color:var(--ink-soft)]">
Last sync {new Date(root.lastSeenAt).toLocaleString()}
{root.expiredAt ? " · stale" : ""}
</div>
</p>
</div>
<Badge>{root.skills.length} skills</Badge>
<Badge variant="default">{root.skills.length} skills</Badge>
</div>
{root.skills.length === 0 ? (
<div className="stat">No skills found in this root.</div>
<p className="text-sm text-[color:var(--ink-soft)]">
No skills found in this root.
</p>
) : (
<div className="telemetry-skill-list">
<div className="flex flex-col gap-1">
{root.skills.map((entry) => (
<div key={`${root.rootId}:${entry.skill.slug}`} className="telemetry-skill-row">
<a
className="telemetry-skill-link"
href={`/${encodeURIComponent(String(entry.skill.ownerUserId))}/${entry.skill.slug}`}
>
<span>{entry.skill.displayName}</span>
<span className="telemetry-skill-slug">/{entry.skill.slug}</span>
</a>
<div className="telemetry-skill-meta mono">
<a
key={`${root.rootId}:${entry.skill.slug}`}
className="flex items-center justify-between rounded-[var(--radius-sm)] px-3 py-2 text-sm no-underline transition-colors hover:bg-[color:var(--surface-muted)]"
href={`/${encodeURIComponent(String(entry.skill.ownerUserId))}/${entry.skill.slug}`}
>
<span className="flex items-center gap-2">
<span className="font-semibold text-[color:var(--ink)]">
{entry.skill.displayName}
</span>
<span className="font-mono text-xs text-[color:var(--ink-soft)]">
/{entry.skill.slug}
</span>
</span>
<span className="font-mono text-xs text-[color:var(--ink-soft)]">
{entry.lastVersion ? `v${entry.lastVersion}` : "v?"}{" "}
{entry.removedAt ? "· removed" : ""}
</div>
</div>
</span>
</a>
))}
</div>
)}
@@ -261,7 +335,7 @@ function InstalledSection(props: {
))}
</div>
)}
</>
</div>
);
}
-106
View File
@@ -1,106 +0,0 @@
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 { 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) => {
setLoading(true);
try {
const data = await convexHttp.query(api.users.listPublic, {
limit: 48,
search: q,
});
setResult(data as UsersLoaderResult);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
setQuery(search.q ?? "");
void fetchUsers(search.q);
}, [search.q, fetchUsers]);
const users = result?.items ?? [];
return (
<main className="browse-page">
<div className="browse-page-header">
<h1 className="browse-title">
Users
{typeof result?.total === "number" ? (
<span className="browse-count">{result.total}</span>
) : 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">
<span className="browse-results-count">
{loading ? "Loading users..." : `${users.length} users`}
</span>
</div>
{loading ? (
<Card>
<div className="loading-indicator">Loading users...</div>
</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>
</div>
) : (
<div className="results-list">
{users.map((user) => (
<UserListItem key={user._id} user={user} />
))}
</div>
)}
</div>
</main>
);
}
+240 -6830
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More