Compare commits

..
Author SHA1 Message Date
Val Alexander e5be24fd13 fix: refine header and about responsiveness 2026-04-13 13:01:18 -05:00
Val Alexander 15bc4440cc style: add claw red accents to hub theme 2026-04-13 10:44:51 -05:00
Val Alexander 11a20f5755 refactor: reduce marketplace themes to claw and hub 2026-04-13 10:38:36 -05:00
Val Alexander 05f8674628 refactor: simplify footer browse links and theme switching 2026-04-13 10:19:15 -05:00
Val Alexander d2b2252770 Merge branch 'main' into staging 2026-04-13 09:48:02 -05:00
Val Alexander a2387253ec style: give knot theme distinct component treatments 2026-04-13 04:22:39 -05:00
Val Alexander 411260767b style: make knot theme darker and purple 2026-04-13 04:18:42 -05:00
Val Alexander 731d0ce0c5 feat: restyle dash theme to match clawhub v2 2026-04-13 03:59:25 -05:00
Val Alexander 361f2affde feat: add clawhub theme families and tweakcn import 2026-04-13 03:19:17 -05:00
Val AlexanderClaude Sonnet 4.6ImLukeFSaurabh Jaindependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>MomopqdongJhollyloongYaovi
a17f7bb07e feat: modernize clawhub app store (#1655)
* build(deps-dev): bump vite in the npm_and_yarn group across 1 directory (#1561)

Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 8.0.1 to 8.0.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.5
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix: detect generated-source template injection in skill scans (#1597)

* fix: detect exposed resource identifiers in skill scans (#1598)

* fix: restore ci checks after lockfile drift

* refactor: address actionable review cleanup (#1601)

* fix: prevent starring soft-deleted skills and fix star count reconciliation (#1605)

* feat: Add support for Chinese Japanese and Korean(CJK) skills search (#1596)

Merged via squash.

Prepared head SHA: ab58f01be7
Co-authored-by: pq-dong <40668796+pq-dong@users.noreply.github.com>
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Reviewed-by: @momothemage

* docs: document CLI config paths across platforms (#1252)

* docs: document CLI config paths across platforms

* docs: clarify legacy config fallback

---------

Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>

* fix: point plugin metadata help link to OpenClaw docs (#1399)

* fix: point plugin metadata help link to OpenClaw docs

* fix: open plugin metadata docs in a new tab

* fix(cli-auth): ensure fallback token renders before redirect on Windows/Chrome (#1486)

* fix(cli-auth): ensure fallback token renders before redirect on Windows/Chrome

React batches state updates, so setToken() and window.location.assign()
previously raced: the navigation could fire before React re-rendered the
fallback token UI. On Chrome/Windows this means a failed http:// redirect
(ERR_CONNECTION_REFUSED, HTTPS-first interference) would replace the page
with an error screen before the user ever saw the token.

Use flushSync() to render the token synchronously, then attempt
window.location.assign(). If the redirect fails the token and a "Retry
redirect to CLI" link are already painted on screen.

Fixes #1469

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: cover cli auth fallback redirect

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>

* fix: reduce souls browse overfetch (#1637)

* fix: improve admin user search coverage (#1466)

* fix admin user search coverage

* fix admin user search without full table scan

* feat: include stats in package detail API response

Expose package detail stats through the shared API contract and the app client.

This lands the original package detail stats work and folds in the follow-up cleanup to keep the response shape sourced from the shared schema instead of a hand-maintained app-local type.

Co-authored-by: Saurabh Jain <saurabhjain1592@gmail.com>

* test: cover package detail stats response

* fix: normalize misleading MIME types for text files

* feat: modernize clawhub app store

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Luke <92253590+ImLukeF@users.noreply.github.com>
Co-authored-by: Momo <35096042+momothemage@users.noreply.github.com>
Co-authored-by: pqdong <40668796+pq-dong@users.noreply.github.com>
Co-authored-by: Jholly <xiangjunkong90@gmail.com>
Co-authored-by: loong <46096863+robinspt@users.noreply.github.com>
Co-authored-by: Yaovi <dkpoga@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Saurabh Jain <saurabhjain1592@gmail.com>
2026-04-13 01:36:18 -05:00
ImLukeF 7980788ea6 fix: normalize misleading MIME types for text files 2026-04-13 15:54:42 +10:00
Val Alexander 2d03b827d3 fix: close plugins route loader return 2026-04-13 00:44:04 -05:00
Val Alexanderandgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> 835094ea2c Update convex/seedDemo.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-13 00:35:16 -05:00
Val Alexandergreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>v0 <v0[bot]@users.noreply.github.com>
7bd7e4c99e Modern utility store (#1646)
* feat: redesign ClawHub marketplace with modern utility store theme

Update styles.css and index.tsx for new modern design

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>

* style: polish marketplace UI with modern design updates

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>

* fix: resolve 500 errors in TanStack Router loaders

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>

* fix: resolve SSR error bubbling in TanStack Router

Ensure loader errors don't escape SSR and hydrate correctly.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>

* feat: handle errors in Package API calls to prevent SSR and HMR errors

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>

* feat: update branding to OpenClaw's black, white, and red color scheme

Implement new color scheme across dark, light themes and interactive elements

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>

* feat: use Vite's native tsconfig paths

Replace deprecated plugin with native option and remove unused import.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>

* fix: add defensive checks in fetchPluginCatalog

Ensure proper handling of undefined and unexpected API responses.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>

* fix: add defensive checks to PluginsIndex for SSR errors

Handle undefined loader data in PluginsIndex component.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>

* refactor: optimize skill detail page layout

Redesign skill detail page to maximize space, remove sidebar, create metadata bar, and add responsive breakpoints.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>

* fix: add global overflow protection to detail pages

Add overflow prevention for text elements and links.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>

* feat: highlight parent tabs with activePathPrefixes

Add activePathPrefixes to NavItem and update navigation to highlight parent tabs.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>

* feat: add user preferences customization section

Add 'usePreferences' hook and new Switch component; enhance settings page with Customization section.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>

* feat: optimize layout and create DESIGN.md

Fix orphan cards, enforce equal card heights, add branding accents, improve visual hierarchy, add responsive breakpoints, create design document

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>

* Update src/routes/plugins/index.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update src/components/layout/Container.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: v0 <v0[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-12 23:23:08 -05:00
Val Alexander 1722a48055 fix: resolve review feedback on layout and auth state (#1652) 2026-04-12 23:21:11 -05:00
Val Alexander 29178898bb fix: resolve review feedback on layout and auth state (#1647) 2026-04-12 22:41:30 -05:00
ImLukeF b16861f422 test: cover package detail stats response 2026-04-12 22:18:01 +10:00
Saurabh Jain 6896e61fa1 feat: include stats in package detail API response
Expose package detail stats through the shared API contract and the app client.

This lands the original package detail stats work and folds in the follow-up cleanup to keep the response shape sourced from the shared schema instead of a hand-maintained app-local type.

Co-authored-by: Saurabh Jain <saurabhjain1592@gmail.com>
2026-04-12 21:59:38 +10:00
Luke cf137aa592 fix: improve admin user search coverage (#1466)
* fix admin user search coverage

* fix admin user search without full table scan
2026-04-12 21:18:19 +10:00
Luke a28d014d4f fix: reduce souls browse overfetch (#1637) 2026-04-12 21:04:12 +10:00
b2038fc931 fix(cli-auth): ensure fallback token renders before redirect on Windows/Chrome (#1486)
* fix(cli-auth): ensure fallback token renders before redirect on Windows/Chrome

React batches state updates, so setToken() and window.location.assign()
previously raced: the navigation could fire before React re-rendered the
fallback token UI. On Chrome/Windows this means a failed http:// redirect
(ERR_CONNECTION_REFUSED, HTTPS-first interference) would replace the page
with an error screen before the user ever saw the token.

Use flushSync() to render the token synchronously, then attempt
window.location.assign(). If the redirect fails the token and a "Retry
redirect to CLI" link are already painted on screen.

Fixes #1469

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: cover cli auth fallback redirect

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>
2026-04-12 19:58:11 +10:00
loong 4a72b543b2 fix: point plugin metadata help link to OpenClaw docs (#1399)
* fix: point plugin metadata help link to OpenClaw docs

* fix: open plugin metadata docs in a new tab
2026-04-12 18:05:57 +10:00
JhollyandImLukeF 54e99c8cc2 docs: document CLI config paths across platforms (#1252)
* docs: document CLI config paths across platforms

* docs: clarify legacy config fallback

---------

Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>
2026-04-12 18:02:41 +10:00
Nimrod Gutman 9a45c371fc fix(ui): align browse page widths across tabs 2026-04-09 14:19:27 +03:00
Val Alexander 298cbdd6db fix: denormalize user hover stats 2026-04-08 15:16:15 -05:00
Val AlexanderandNova f636b31fca feat: design foundation sweep (#1570) thanks @BunsDev
Co-authored-by: Nova <nova@openknot.ai>
2026-04-07 07:48:20 -05:00
Val Alexander b255b5865f Merge pull request #1567 from openclaw/okcode/conflict-resolution-plan
feat: marketplace UI overhaul with main security fixes
2026-04-07 02:07:33 -05:00
Val AlexanderandClaude Opus 4.6 5003c1bec8 fix: address remaining PR review comments
- Re-throw non-rate-limit errors in plugin loader so route error
  boundary handles real failures instead of showing empty results
- Bump requestRef on query clear to invalidate in-flight searches
  and prevent stale results from repopulating
- Replace Promise.all with Promise.allSettled in unified search so
  one failing provider doesn't blank results from other sources
- Log unexpected errors in unified search catch block

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 02:04:19 -05:00
Val AlexanderandClaude Opus 4.6 a8a6242f87 fix: address PR review — reason guard, one-shot fetch, index scan
- Reject empty reason strings in setSoftDeleted calls + re-add .catch()
  for error feedback (both reported-skills and skill-tools sections)
- Replace useQuery with ConvexHttpClient.query() on /users public
  browse page per CLAUDE.md policy
- Add by_active_handle compound index on users table to avoid full
  table scan in queryUsersForPublicList

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 00:55:41 -05:00
Val AlexanderandClaude Opus 4.6 39c0fa2531 merge: integrate origin/main into feat/marketplace-ui-overhaul
Keep feature branch UI overhaul (custom CSS) while incorporating
security/stability fixes from main:
- setSoftDeleted now requires moderation reason (runtime-critical)
- moderationNotes displayed in skill detail when available
- Rate limit handling for plugin catalog
- Tailwind @theme block for auto-merged component compatibility
- Capability tag passthrough to SecurityScanResults
- ALL_CATEGORY_KEYWORDS export for skills browse model

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 00:28:17 -05:00
Vincent Koc 051b1dafcd fix: audit fixes — warm colors, lint, ARIA labels
- Replace ~20 remaining warm hex colors in upload/form styles
  (#ffddc9, #9a3a24, #fff3ec, etc.) with monochrome equivalents
- Fix 2 lint errors: remove unused Link import (search.tsx),
  prefix unused parseDir with underscore (souls/index.tsx)
- Add aria-label to PluginListItem and UserListItem for
  screen reader identification
2026-04-06 22:58:12 +01:00
Vincent Koc 383844cacf chore: various component changes 2026-04-06 22:03:49 +01:00
Vincent Koc b7923edbfd Update .gitignore 2026-04-06 20:41:09 +01:00
Vincent Koc 522fa22026 fix: polish pass — tighter spacing, no empty sections, subtle skeletons
- Home page sections only render when data arrives (no skeleton flash)
- Removed SkeletonRows component from home page (unused)
- Skeleton bars simplified: static gray bars, no shimmer animation
- Skeleton row padding matches list item padding
- Hero padding tightened (48px top → 32px)
- Hero subtitle made concise ("20 skill bundles... Browse, install, publish.")
- Removed redundant explainer paragraph
- Browse results count shows em dash while loading (not "Loading...")
- .section class uses spacing tokens
- .section-title uses monospace font at --fs-lg
- .section-subtitle uses --fs-sm
- results-list removed fadeIn animation (subtle state changes only)
2026-04-04 19:26:44 +09:00
ImLukeF e41d5d6314 fix: restore banned accounts in management 2026-04-04 20:49:24 +11:00
Vincent Koc c62ab8bc96 fix: complete audit cleanup — monochrome purity, touch targets, a11y
Theming (P0):
- Removed all 78 [data-theme="dark"] override selectors (dark is now
  default, these were dead code with conflicting warm colors)
- Replaced 56 instances of rgba(255,107,74,x) warm coral with
  rgba(255,255,255,x) monochrome equivalents
- Replaced hard-coded warm hex colors (#c35640, #ff6b4a, etc.) with
  gray monochrome values
- CSS file reduced from ~6300 to 5909 lines

Touch targets (P1):
- Added min-height: 36px to .btn (was ~24px)
- Added min-height: 36px to .navbar-tab (was ~27px)
- Added min-height: 32px to .sidebar-option and .sidebar-checkbox
- Increased padding on buttons and tabs

Accessibility (P2/P3):
- Comprehensive prefers-reduced-motion: reduce rule — disables all
  animations AND transitions for users who prefer reduced motion
- Covers shimmer, fadeIn, fadeUp, and all CSS transitions
2026-04-04 17:15:28 +09:00
Vincent Koc 6df3a3b9ef feat: monochrome dark TUI aesthetic — complete design pivot
Complete visual redesign to a dark, monochrome, terminal-inspired
aesthetic inspired by Warp, modern TUI tools, and blueprint designs.

Color system:
- Default is now dark (#0a0a0a bg, #e0e0e0 ink, #141414 surface)
- All accent colors removed — monochrome only (white as accent)
- Borders use rgba(255,255,255,0.08) for subtle separation
- Light theme available as optional override via [data-theme="light"]

Typography:
- All fonts now IBM Plex Mono (display, body, code all monospace)
- Brand name is lowercase monospace
- Section titles are uppercase monospace with letter-spacing
- Tags and badges use monospace font

Geometry:
- All border-radius reduced to 1-2px (sharp TUI corners)
- No shadows anywhere (--shadow: none)
- No backdrop-filter blur on navbar
- Cards, buttons, inputs all have sharp edges

Components:
- Buttons: transparent bg with border, monospace text
- Primary buttons: white on black (inverted)
- Tags: border-only, no colored backgrounds
- Cards: dark surface with subtle border
- Brand mark: 24px square instead of 28px circle

Layout:
- Replaced category grid with simple quick links
- Removed all warm color references
- Home section titles are small uppercase labels
- Skill list item names use --ink (no accent color)
2026-04-04 16:08:12 +09:00
Vincent Koc 7452cf6f69 feat: white background, onboarding explainer, filter reset, transitions
- Switch light theme from warm beige (#f8f2ed) to neutral white (#fafafa)
  with neutral gray ink (#1a1a1a) and borders (rgba black)
- Switch dark theme from warm brown to neutral dark (#111111) with
  neutral gray borders (rgba white)
- Replace fake category grid (8 keyword-search cards) with curated
  quick links (Most starred, New this week, Browse plugins, Staff picks)
- Add "What are skills?" explainer paragraph below hero CTAs
- Add fadeIn animation on results list when data arrives
- Add "Clear" button in browse results toolbar when filters are active
- Tighten browse layout gap from 24px to 16px
2026-04-04 15:01:57 +09:00
Vincent Koc b4e8a26eb4 chore: remove accidentally committed skill/agent config files 2026-04-04 00:01:59 +09:00
Vincent Koc 7ff601bcb8 fix: resolve all lint errors
- Remove unused imports (v, getRuntimeEnv) from seedDemo.ts and SkillHeader.tsx
- Replace `as any` casts with proper Id<"publishers"> types in seedDemo.ts
- Prefix unused params with underscore (_clawdis, _osLabels, _nixSystems, _listDoneLoading)
- Remove unused convexSiteUrl variable from SkillHeader
2026-04-04 00:01:43 +09:00
Vincent Koc 70af109cb2 fix: address PR review feedback
- Import internalMutation from convex/functions (not _generated/server)
  to get trigger wrapping per CLAUDE.md rules
- Derive activeCategory from current search query so sidebar category
  selection shows correct visual/ARIA state
- Push moderationStatus filter server-side in repairGlobalStats to
  avoid full table scan
- Reset skillCount/pluginCount to 0 in useUnifiedSearch catch block
  to prevent stale badge values after search errors
2026-04-03 23:41:54 +09:00
Vincent Koc 1504708208 feat: marketplace UI overhaul — HuggingFace/npm-style discovery hub
Complete frontend rebuild of ClawHub into a marketplace-style discovery
hub inspired by HuggingFace and npm. No backend changes.

Navigation:
- Two-row header: brand + search bar + user actions on top, content
  type tabs (Skills, Plugins) with count badges below
- Inline search in navbar navigates to /search
- Mobile: search collapses behind icon, tabs scroll horizontally

Home page:
- Value-prop hero with Browse/Publish CTAs (no duplicate search)
- Trending section (8 skills by downloads)
- Recently updated section (8 skills by update time)
- Staff picks grid (6 highlighted skill cards)
- Browse by category grid (8 categories with Lucide icons)
- Skeleton loading rows while data fetches

Browse pages (Skills + Plugins):
- Full-width search bar above sidebar+results grid
- Left sidebar with sort options, categories, filter checkboxes
  (proper ARIA: fieldset/legend, role=radiogroup, aria-checked)
- List view uses compact SkillListItem rows (owner/name/summary/meta)
- Card view with hover border feedback
- View toggle (List/Cards)
- Better empty states with guidance text

Skill detail page:
- README tab as default (was Files)
- Two-column layout: tabs+comments on left, metadata sidebar on right
- Removed duplicate README from Files tab
- Removed duplicate Download button from header (kept in sidebar)
- Removed SkillInstallCard from header (license info in sidebar)
- Nix/config snippets moved inside two-column layout
- Friendly "No README available" instead of raw Convex errors

Unified search (/search):
- Real search results page (was redirect-only)
- Type tabs: All / Skills / Plugins with counts
- useUnifiedSearch hook fires skill search + plugin catalog in parallel
- Consistent SkillListItem rendering for results

Dashboard:
- Welcome state for new users with empty dashboard
- Simplified header copy

Profiles & Footer:
- Richer user profiles: large avatar, stat row, SkillListItem for
  published/starred skills
- Multi-column footer: Browse / Publish / Community / Platform

Design system:
- Spacing tokens: --space-1 (4px) through --space-8 (64px)
- Typography scale: --fs-xs through --fs-3xl (8 values, was 42)
- Radius tokens: --r-lg/md/sm/xs/pill (renamed from --radius-* to
  avoid Tailwind CSS v4 variable collision)
- Flat buttons (killed gradient, removed hover lift/shadow)
- Complete markdown styles: tables, blockquotes, lists, images, hr,
  heading hierarchy with h1/h2 bottom borders (npm-style)
- Removed decorative elements: body gradient backgrounds, card
  shadows, brand mark animation, category card glow

New components:
- SkillListItem — compact HF-style row
- BrowseSidebar — faceted filter sidebar with ARIA
- SkillMetadataSidebar — detail page right sidebar
- useUnifiedSearch — parallel search hook
- timeAgo — relative time formatter
- categories — static skill category taxonomy

Seed data:
- seedDemo.ts with 20 realistic skills, 5 publishers
- repairHighlightedBadges for skillBadges table
- repairGlobalStats for correct count

Test updates:
- Updated 5 test files for new text, class names, and prop changes
- All 122 test files, 915 tests passing
2026-04-03 22:58:30 +09:00
144 changed files with 14833 additions and 5159 deletions
+4
View File
@@ -27,9 +27,13 @@ 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,3 +24,7 @@ coverage
playwright-report
test-results
.playwright
convex/_generated/
skills-lock.json
*/skills/*
skills/*
+8
View File
@@ -87,3 +87,11 @@
- **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 -->
-1
View File
@@ -5,7 +5,6 @@
### Changed
- Search: add CJK tokenization support (Chinese/Japanese/Korean) with Intl.Segmenter plus fallback behavior to improve skill query matching (#1596) (thanks @pq-dong).
- Skill ownership transfers now support org targets: `transfer request` and `transfer accept` accept a `--publisher` flag to send or receive a skill on behalf of an org. Org admins can see incoming org-targeted transfers via `GET /api/v1/transfers/incoming`. (#1603 thanks @TommYDeeee)
## 0.10.0 - 2026-04-05
+8
View File
@@ -45,3 +45,11 @@
- 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
@@ -0,0 +1,356 @@
# 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
+139 -3
View File
@@ -20,6 +20,8 @@
"@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",
@@ -32,6 +34,7 @@
"@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",
@@ -41,6 +44,8 @@
"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",
@@ -51,6 +56,7 @@
"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",
@@ -266,6 +272,56 @@
"@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=="],
@@ -282,6 +338,24 @@
"@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=="],
@@ -440,7 +514,9 @@
"@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.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-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-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=="],
@@ -536,6 +612,8 @@
"@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=="],
@@ -744,6 +822,8 @@
"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"],
@@ -752,6 +832,8 @@
"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=="],
@@ -1096,6 +1178,10 @@
"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=="],
@@ -1224,6 +1310,8 @@
"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=="],
@@ -1264,6 +1352,8 @@
"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=="],
@@ -1308,6 +1398,8 @@
"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=="],
@@ -1400,10 +1492,14 @@
"@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=="],
@@ -1416,14 +1512,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=="],
@@ -1436,6 +1532,12 @@
"@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=="],
@@ -1450,6 +1552,8 @@
"@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=="],
@@ -1492,7 +1596,11 @@
"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=="],
@@ -1510,6 +1618,33 @@
"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=="],
@@ -1583,5 +1718,6 @@
"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,6 +100,7 @@ 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";
@@ -216,6 +217,7 @@ 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,6 +469,11 @@ 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,
+45
View File
@@ -2552,6 +2552,51 @@ describe("httpApiV1 handlers", () => {
});
});
it("packages detail returns stats for plugins", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("name" in args) {
return {
package: {
_id: "packages:demo-plugin",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
tags: {},
latestReleaseId: "packageReleases:1",
channel: "community",
isOfficial: false,
summary: "Plugin summary",
latestVersion: "1.2.3",
stats: { downloads: 7, installs: 3, stars: 2, versions: 4 },
createdAt: 1,
updatedAt: 2,
},
latestRelease: null,
owner: { _id: "users:owner", handle: "owner", displayName: "Owner" },
};
}
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/packages/demo-plugin"),
);
if (response.status !== 200) throw new Error(await response.text());
await expect(response.json()).resolves.toMatchObject({
package: {
name: "demo-plugin",
latestVersion: "1.2.3",
stats: { downloads: 7, installs: 3, stars: 2, versions: 4 },
},
owner: {
handle: "owner",
},
});
});
it("packages file serves SKILL.md for skill README requests", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("name" in args) return null;
+1
View File
@@ -1433,6 +1433,7 @@ type PublicPackageDocLike = {
compatibility?: Doc<"packages">["compatibility"];
capabilities?: Doc<"packages">["capabilities"];
verification?: Doc<"packages">["verification"];
stats?: { downloads: number; installs: number; stars: number; versions: number };
createdAt: number;
updatedAt: number;
};
+6 -4
View File
@@ -1,4 +1,4 @@
import { CliPublishRequestSchema, parseArk } from "clawhub-schema";
import { CliPublishRequestSchema, normalizeTextContentType, parseArk } from "clawhub-schema";
import { internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
@@ -25,7 +25,9 @@ export function safeTextFileResponse(params: {
size: number;
headers?: HeadersInit;
}) {
const isSvg = isSvgLike(params.contentType, params.path);
const contentType =
normalizeTextContentType(params.path, params.contentType) ?? params.contentType;
const isSvg = isSvgLike(contentType, params.path);
// For any text response that a browser might try to render, lock it down.
// In particular, this prevents SVG <foreignObject> script execution from reading
@@ -33,8 +35,8 @@ export function safeTextFileResponse(params: {
const headers = mergeHeaders(
params.headers,
{
"Content-Type": params.contentType
? `${params.contentType}; charset=utf-8`
"Content-Type": contentType
? `${contentType}; charset=utf-8`
: "text/plain; charset=utf-8",
"Cache-Control": "private, max-age=60",
ETag: params.sha256,
+21 -73
View File
@@ -1,4 +1,5 @@
import { api, internal } from "../_generated/api";
import { normalizeTextContentType } from "clawhub-schema";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenAuth";
@@ -734,7 +735,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
path: file.path,
size: file.size,
sha256: file.sha256,
contentType: file.contentType ?? null,
contentType: normalizeTextContentType(file.path, file.contentType) ?? null,
})),
security: security ?? undefined,
},
@@ -969,26 +970,11 @@ async function handleTransferRequest(
if (!toUserHandleRaw) return text("toUserHandle required", 400, headers);
const message = typeof parsed.payload.message === "string" ? parsed.payload.message : undefined;
// Resolve optional publisher handle to a publisher ID
const toPublisherHandleRaw =
typeof parsed.payload.toPublisherHandle === "string"
? parsed.payload.toPublisherHandle.trim()
: "";
let toPublisherId: Id<"publishers"> | undefined;
if (toPublisherHandleRaw) {
const publisher = await ctx.runQuery(internal.publishers.getByHandleInternal, {
handle: toPublisherHandleRaw,
});
if (!publisher) return text("Publisher not found", 404, headers);
toPublisherId = publisher._id;
}
try {
const result = await ctx.runMutation(internal.skillTransfers.requestTransferInternal, {
actorUserId: transferContext.userId,
skillId: transferContext.skill._id,
toUserHandle: toUserHandleRaw,
toPublisherId,
message,
});
return json(result, 200, headers);
@@ -1007,68 +993,30 @@ async function handleTransferDecision(
const transferContext = await resolveTransferContext(ctx, request, slug, headers);
if (!transferContext.ok) return transferContext.response;
let pendingTransfer;
if (decision === "cancel") {
pendingTransfer = await ctx.runQuery(
internal.skillTransfers.getPendingTransferBySkillAndFromUserInternal,
{ skillId: transferContext.skill._id, fromUserId: transferContext.userId },
);
if (!pendingTransfer) {
// Fallback: allow org admins to cancel transfers initiated by other admins
pendingTransfer = await ctx.runQuery(
internal.skillTransfers.getPendingTransferBySkillInternal,
{ skillId: transferContext.skill._id },
);
}
} else {
// Try user-specific lookup first, then fall back to any pending transfer
// for the skill (allows org admins other than toUserId to accept/reject)
pendingTransfer = await ctx.runQuery(
internal.skillTransfers.getPendingTransferBySkillAndUserInternal,
{ skillId: transferContext.skill._id, toUserId: transferContext.userId },
);
if (!pendingTransfer) {
pendingTransfer = await ctx.runQuery(
internal.skillTransfers.getPendingTransferBySkillInternal,
{ skillId: transferContext.skill._id },
);
}
}
const pendingTransfer =
decision === "cancel"
? await ctx.runQuery(internal.skillTransfers.getPendingTransferBySkillAndFromUserInternal, {
skillId: transferContext.skill._id,
fromUserId: transferContext.userId,
})
: await ctx.runQuery(internal.skillTransfers.getPendingTransferBySkillAndUserInternal, {
skillId: transferContext.skill._id,
toUserId: transferContext.userId,
});
if (!pendingTransfer) return text("No pending transfer found", 404, headers);
try {
// For accept, resolve optional publisher handle to forward publisherId
let publisherId: Id<"publishers"> | undefined;
if (decision === "accept") {
const parsed = await parseJsonPayload(request, headers);
if (parsed.ok) {
const publisherHandleRaw =
typeof parsed.payload.publisherHandle === "string"
? parsed.payload.publisherHandle.trim()
: "";
if (publisherHandleRaw) {
const publisher = await ctx.runQuery(internal.publishers.getByHandleInternal, {
handle: publisherHandleRaw,
});
if (!publisher) return text("Publisher not found", 404, headers);
publisherId = publisher._id;
}
}
}
const mutation =
decision === "accept"
? internal.skillTransfers.acceptTransferInternal
: decision === "reject"
? internal.skillTransfers.rejectTransferInternal
: internal.skillTransfers.cancelTransferInternal;
const baseArgs = {
try {
const result = await ctx.runMutation(mutation, {
actorUserId: transferContext.userId,
transferId: pendingTransfer._id,
};
const result = await (decision === "accept"
? ctx.runMutation(internal.skillTransfers.acceptTransferInternal, {
...baseArgs,
publisherId,
})
: decision === "reject"
? ctx.runMutation(internal.skillTransfers.rejectTransferInternal, baseArgs)
: ctx.runMutation(internal.skillTransfers.cancelTransferInternal, baseArgs));
});
return json(result, 200, headers);
} catch (error) {
return transferErrorToResponse(error, headers);
+1 -6
View File
@@ -16,14 +16,9 @@ export async function transfersGetRouterV1Handler(ctx: ActionCtx, request: Reque
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const skillTransfers =
const transfers =
direction === "incoming"
? await ctx.runQuery(internal.skillTransfers.listIncomingInternal, { userId: auth.userId })
: await ctx.runQuery(internal.skillTransfers.listOutgoingInternal, { userId: auth.userId });
const transfers = skillTransfers.sort(
(a, b) => (b.requestedAt ?? 0) - (a.requestedAt ?? 0),
);
return json({ transfers }, 200, rate.headers);
}
+1 -1
View File
@@ -1,6 +1,6 @@
/* @vitest-environment node */
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
extractWorkflowFilenameFromWorkflowRef,
verifyGitHubActionsTrustedPublishJwt,
+2
View File
@@ -1,4 +1,5 @@
import { ConvexError } from "convex/values";
import { normalizeTextContentType } from "clawhub-schema";
import semver from "semver";
import { api, internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
@@ -113,6 +114,7 @@ export async function publishVersionForUser(
const sanitizedFiles = args.files.map((file) => ({
...file,
path: sanitizePath(file.path),
contentType: normalizeTextContentType(file.path, file.contentType),
}));
if (sanitizedFiles.some((file) => !file.path)) {
throw new ConvexError("Invalid file paths");
+6 -1
View File
@@ -1,4 +1,5 @@
import { ConvexError } from "convex/values";
import { normalizeTextContentType } from "clawhub-schema";
import semver from "semver";
import { internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
@@ -101,7 +102,11 @@ export async function publishSoulVersionForUser(
const sanitizedFiles = args.files.map((file) => {
const path = sanitizePath(file.path);
if (!path) throw new ConvexError("Invalid file paths");
return { ...file, path };
return {
...file,
path,
contentType: normalizeTextContentType(file.path, file.contentType),
};
});
const publishFiles = sanitizedFiles.filter((file) => !isMacJunkPath(file.path));
if (publishFiles.some((file) => !isTextFile(file.path, file.contentType ?? undefined))) {
-309
View File
@@ -1,309 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
TRANSFER_EXPIRY_MS,
isTransferExpired,
normalizeTransferHandle,
validateTransferOwnership,
validateTransferAcceptPermission,
} from "./transfers";
describe("transfers", () => {
it("TRANSFER_EXPIRY_MS is 7 days", () => {
expect(TRANSFER_EXPIRY_MS).toBe(7 * 24 * 60 * 60 * 1000);
});
describe("isTransferExpired", () => {
it("returns true when expiresAt is in the past", () => {
expect(isTransferExpired({ expiresAt: 1000 }, 2000)).toBe(true);
});
it("returns false when expiresAt is in the future", () => {
expect(isTransferExpired({ expiresAt: 3000 }, 2000)).toBe(false);
});
it("returns false when expiresAt equals now", () => {
expect(isTransferExpired({ expiresAt: 2000 }, 2000)).toBe(false);
});
});
describe("normalizeTransferHandle", () => {
it("trims whitespace", () => {
expect(normalizeTransferHandle(" alice ")).toBe("alice");
});
it("strips leading @ and lowercases", () => {
expect(normalizeTransferHandle("@Alice")).toBe("alice");
});
it("strips multiple leading @ signs", () => {
expect(normalizeTransferHandle("@@Bob")).toBe("bob");
});
it("lowercases without @", () => {
expect(normalizeTransferHandle("Charlie")).toBe("charlie");
});
});
describe("validateTransferOwnership", () => {
it("passes for direct owner (personal, no publisher)", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn(),
},
};
await expect(
validateTransferOwnership(ctx as never, {
actorUserId: "users:1" as never,
ownerUserId: "users:1" as never,
ownerPublisherId: undefined,
}),
).resolves.toBeUndefined();
});
it("passes for org admin", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org1",
userId: "users:1",
role: "admin",
}),
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
validateTransferOwnership(ctx as never, {
actorUserId: "users:1" as never,
ownerUserId: "users:99" as never,
ownerPublisherId: "publishers:org1" as never,
}),
).resolves.toBeUndefined();
});
it("passes for org owner role", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org1",
userId: "users:1",
role: "owner",
}),
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
validateTransferOwnership(ctx as never, {
actorUserId: "users:1" as never,
ownerUserId: "users:99" as never,
ownerPublisherId: "publishers:org1" as never,
}),
).resolves.toBeUndefined();
});
it("rejects non-admin org member (publisher role)", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org1",
userId: "users:1",
role: "publisher",
}),
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
validateTransferOwnership(ctx as never, {
actorUserId: "users:1" as never,
ownerUserId: "users:99" as never,
ownerPublisherId: "publishers:org1" as never,
}),
).rejects.toThrow("Forbidden");
});
it("rejects non-owner non-member", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => null,
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
validateTransferOwnership(ctx as never, {
actorUserId: "users:1" as never,
ownerUserId: "users:99" as never,
ownerPublisherId: "publishers:org1" as never,
}),
).rejects.toThrow("Forbidden");
});
it("rejects personal item when actor is not the owner", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn(),
},
};
await expect(
validateTransferOwnership(ctx as never, {
actorUserId: "users:2" as never,
ownerUserId: "users:1" as never,
ownerPublisherId: undefined,
}),
).rejects.toThrow("Forbidden");
});
});
describe("validateTransferAcceptPermission", () => {
it("passes for personal target when actor is the target user", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn(),
},
};
await expect(
validateTransferAcceptPermission(ctx as never, {
actorUserId: "users:1" as never,
toUserId: "users:1" as never,
toPublisherId: undefined,
}),
).resolves.toBeUndefined();
});
it("rejects personal target when actor is not the target user", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn(),
},
};
await expect(
validateTransferAcceptPermission(ctx as never, {
actorUserId: "users:2" as never,
toUserId: "users:1" as never,
toPublisherId: undefined,
}),
).rejects.toThrow("No pending transfer found");
});
it("passes for org target when actor is admin", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
get: vi.fn(async () => ({
_id: "publishers:org1",
kind: "org",
handle: "myorg",
})),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org1",
userId: "users:1",
role: "admin",
}),
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
validateTransferAcceptPermission(ctx as never, {
actorUserId: "users:1" as never,
toUserId: "users:99" as never,
toPublisherId: "publishers:org1" as never,
}),
).resolves.toBeUndefined();
});
it("rejects org target when actor is not admin/owner", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
get: vi.fn(async () => ({
_id: "publishers:org1",
kind: "org",
handle: "myorg",
})),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org1",
userId: "users:1",
role: "publisher",
}),
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
validateTransferAcceptPermission(ctx as never, {
actorUserId: "users:1" as never,
toUserId: "users:99" as never,
toPublisherId: "publishers:org1" as never,
}),
).rejects.toThrow("No pending transfer found");
});
});
});
-91
View File
@@ -1,91 +0,0 @@
import type { Doc, Id } from "../_generated/dataModel";
import type { QueryCtx, MutationCtx } from "../_generated/server";
import { getPublisherMembership, isPublisherActive, isPublisherRoleAllowed } from "./publishers";
type DbCtx = Pick<QueryCtx | MutationCtx, "db">;
/** 7 days in milliseconds */
export const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
/** Returns true if the transfer has expired (expiresAt is strictly less than now). */
export function isTransferExpired(transfer: { expiresAt: number }, now: number): boolean {
return transfer.expiresAt < now;
}
/**
* Trims whitespace, strips leading `@` characters, and lowercases:
* `"@Alice"` -> `"alice"`, `"@@Bob"` -> `"bob"`
*/
export function normalizeTransferHandle(value: string): string {
return value.trim().replace(/^@+/, "").toLowerCase();
}
/**
* Validates that `actorUserId` has permission to initiate a transfer.
*
* - If the item is personally owned (`ownerPublisherId` is null/undefined):
* actor must be the `ownerUserId`.
* - If the item is org-owned: actor must be an admin or owner in that org's
* publisherMembers.
*
* @throws {Error} "Forbidden" on failure
*/
export async function validateTransferOwnership(
ctx: DbCtx,
params: {
actorUserId: Id<"users">;
ownerUserId: Id<"users">;
ownerPublisherId?: Id<"publishers"> | null;
},
): Promise<void> {
if (!params.ownerPublisherId) {
// Personally owned — actor must be the owner
if (params.actorUserId !== params.ownerUserId) {
throw new Error("Forbidden");
}
return;
}
// Org-owned — actor must be admin or owner in the org
const membership = await getPublisherMembership(ctx, params.ownerPublisherId, params.actorUserId);
if (!membership || !isPublisherRoleAllowed(membership.role, ["admin"])) {
throw new Error("Forbidden");
}
}
/**
* Validates that `actorUserId` can accept a transfer.
*
* - If `toPublisherId` is null (personal target): actor must be `toUserId`.
* - If `toPublisherId` is set (org target): actor must be admin/owner of that org.
*
* @throws {Error} "No pending transfer found" on failure
*/
export async function validateTransferAcceptPermission(
ctx: DbCtx,
params: {
actorUserId: Id<"users">;
toUserId?: Id<"users"> | null;
toPublisherId?: Id<"publishers"> | null;
},
): Promise<void> {
if (!params.toPublisherId) {
// Personal target — actor must be the target user
if (params.actorUserId !== params.toUserId) {
throw new Error("No pending transfer found");
}
return;
}
// Org target — publisher must be active and actor must be admin or owner
const db = (ctx as { db: { get: (id: Id<"publishers">) => Promise<Doc<"publishers"> | null> } })
.db;
const publisher = await db.get(params.toPublisherId);
if (!isPublisherActive(publisher)) {
throw new Error("Publisher not found");
}
const membership = await getPublisherMembership(ctx, params.toPublisherId, params.actorUserId);
if (!membership || !isPublisherRoleAllowed(membership.role, ["admin"])) {
throw new Error("No pending transfer found");
}
}
+67
View File
@@ -0,0 +1,67 @@
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,
});
}
}
+2
View File
@@ -221,6 +221,7 @@ type PublicPackageDoc = {
capabilities?: Doc<"packages">["capabilities"];
verification?: Doc<"packages">["verification"];
scanStatus?: Doc<"packages">["scanStatus"];
stats: Doc<"packages">["stats"];
createdAt: number;
updatedAt: number;
};
@@ -326,6 +327,7 @@ function toPublicPackage(
capabilities: pkg.capabilities,
verification: pkg.verification,
scanStatus: pkg.scanStatus,
stats: pkg.stats,
createdAt: pkg.createdAt,
updatedAt: pkg.updatedAt,
};
+8 -6
View File
@@ -28,6 +28,9 @@ 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()),
@@ -40,7 +43,8 @@ const users = defineTable({
})
.index("email", ["email"])
.index("phone", ["phone"])
.index("handle", ["handle"]);
.index("handle", ["handle"])
.index("by_active_handle", ["deletedAt", "deactivatedAt", "handle"]);
const publishers = defineTable({
kind: v.union(v.literal("user"), v.literal("org")),
@@ -384,7 +388,8 @@ const souls = defineTable({
.index("by_slug", ["slug"])
.index("by_owner", ["ownerUserId"])
.index("by_owner_publisher", ["ownerPublisherId"])
.index("by_updated", ["updatedAt"]);
.index("by_updated", ["updatedAt"])
.index("by_active_updated", ["softDeletedAt", "updatedAt"]);
const skillVersions = defineTable({
skillId: v.id("skills"),
@@ -1286,9 +1291,7 @@ const userSkillRootInstalls = defineTable({
const skillOwnershipTransfers = defineTable({
skillId: v.id("skills"),
fromUserId: v.id("users"),
toUserId: v.optional(v.id("users")),
fromPublisherId: v.optional(v.id("publishers")),
toPublisherId: v.optional(v.id("publishers")),
toUserId: v.id("users"),
status: v.union(
v.literal("pending"),
v.literal("accepted"),
@@ -1305,7 +1308,6 @@ const skillOwnershipTransfers = defineTable({
.index("by_from_user", ["fromUserId"])
.index("by_to_user", ["toUserId"])
.index("by_to_user_status", ["toUserId", "status"])
.index("by_to_publisher_status", ["toPublisherId", "status"])
.index("by_from_user_status", ["fromUserId", "status"])
.index("by_skill_status", ["skillId", "status"]);
+419
View File
@@ -0,0 +1,419 @@
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,6 +23,7 @@ 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:
@@ -259,6 +260,7 @@ 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
-75
View File
@@ -389,79 +389,4 @@ describe("skillTransfers", () => {
expect.objectContaining({ ownerUserId: "users:2" }),
);
});
it("requestTransferInternal allows org admin to request transfer", async () => {
// Org admin (users:2) can request transfer of a skill owned by org (publishers:org1)
// even though ownerUserId is users:1
const insert = vi.fn(async (table: string) => {
if (table === "skillOwnershipTransfers") return "skillOwnershipTransfers:new";
return "auditLogs:1";
});
const result = (await requestTransferInternalHandler(
{
db: {
normalizeId: vi.fn(),
get: vi.fn(async (id: string) => {
if (id === "users:2") return { _id: "users:2", handle: "orgadmin" };
if (id === "skills:1") {
return {
_id: "skills:1",
slug: "demo",
displayName: "Demo",
ownerUserId: "users:1",
ownerPublisherId: "publishers:org1",
};
}
if (id === "publishers:org1") {
return { _id: "publishers:org1", kind: "org", handle: "myorg" };
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "users") {
return {
withIndex: () => ({
unique: async () => ({
_id: "users:3",
handle: "recipient",
displayName: "Recipient",
}),
}),
};
}
if (table === "skillOwnershipTransfers") {
return { withIndex: () => ({ collect: async () => [] }) };
}
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org1",
userId: "users:2",
role: "admin",
}),
}),
};
}
if (table === "publishers") {
return { withIndex: () => ({ unique: async () => null }) };
}
throw new Error(`unexpected table ${table}`);
}),
patch: vi.fn(async () => {}),
insert,
},
} as never,
{
actorUserId: "users:2",
skillId: "skills:1",
toUserHandle: "@recipient",
} as never,
)) as { ok: boolean; transferId: string };
expect(result.ok).toBe(true);
expect(result.transferId).toBe("skillOwnershipTransfers:new");
});
});
+37 -174
View File
@@ -5,16 +5,18 @@ import {
ensurePersonalPublisherForUser,
getActiveUserByHandleOrPersonalPublisher,
} from "./lib/publishers";
import {
TRANSFER_EXPIRY_MS,
isTransferExpired,
normalizeTransferHandle,
validateTransferOwnership,
validateTransferAcceptPermission,
} from "./lib/transfers";
const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
type TransferDoc = Doc<"skillOwnershipTransfers">;
function normalizeHandle(value: string) {
return value.trim().replace(/^@+/, "").toLowerCase();
}
function isExpired(transfer: TransferDoc, now: number) {
return transfer.expiresAt < now;
}
async function requireActiveUserById(ctx: unknown, userId: Id<"users">) {
const db = (ctx as { db: { get: (id: Id<"users">) => Promise<Doc<"users"> | null> } }).db;
const user = await db.get(userId);
@@ -51,7 +53,7 @@ async function getActivePendingTransferForSkill(ctx: unknown, skillId: Id<"skill
let active: TransferDoc | null = null;
for (const transfer of transfers) {
if (isTransferExpired(transfer, now)) {
if (isExpired(transfer, now)) {
await db.patch(transfer._id, { status: "expired", respondedAt: now });
continue;
}
@@ -81,26 +83,14 @@ async function validatePendingTransferForActor(
const transfer = await db.get(params.transferId);
if (!transfer) throw new Error("Transfer not found");
if (
params.role === "recipient" &&
transfer.toUserId &&
transfer.toUserId !== params.actorUserId &&
!transfer.toPublisherId
) {
// For org-targeted transfers (toPublisherId is set), skip this check —
// validateTransferAcceptPermission handles org membership validation separately
if (params.role === "recipient" && transfer.toUserId !== params.actorUserId) {
throw new Error("No pending transfer found");
}
if (params.role === "sender") {
if (!transfer.fromPublisherId && transfer.fromUserId !== params.actorUserId) {
// Personal transfer: actor must be the original sender
throw new Error("No pending transfer found");
}
// Org-owned transfer: actor's org membership is verified by the caller
// (e.g. cancelTransferInternal calls validateTransferOwnership after this)
if (params.role === "sender" && transfer.fromUserId !== params.actorUserId) {
throw new Error("No pending transfer found");
}
if (transfer.status !== "pending") throw new Error("No pending transfer found");
if (isTransferExpired(transfer, params.now)) {
if (isExpired(transfer, params.now)) {
await db.patch(transfer._id, { status: "expired", respondedAt: params.now });
throw new Error("Transfer has expired");
}
@@ -112,7 +102,6 @@ export const requestTransferInternal = internalMutation({
actorUserId: v.id("users"),
skillId: v.id("skills"),
toUserHandle: v.string(),
toPublisherId: v.optional(v.id("publishers")),
message: v.optional(v.string()),
},
handler: async (ctx, args) => {
@@ -121,28 +110,14 @@ export const requestTransferInternal = internalMutation({
const skill = await ctx.db.get(args.skillId);
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
if (skill.ownerUserId !== args.actorUserId) throw new Error("Forbidden");
await validateTransferOwnership(ctx, {
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId,
actorUserId: args.actorUserId,
});
const toHandle = normalizeTransferHandle(args.toUserHandle);
const toHandle = normalizeHandle(args.toUserHandle);
if (!toHandle) throw new Error("toUserHandle required");
const toUser = await getActiveUserByHandleOrPersonalPublisher(ctx, toHandle);
if (!toUser) throw new Error("User not found");
if (toUser._id === args.actorUserId && !args.toPublisherId) {
throw new Error("Cannot transfer to yourself");
}
if (args.toPublisherId) {
const toPublisher = await ctx.db.get(args.toPublisherId);
if (!toPublisher || toPublisher.deletedAt || toPublisher.deactivatedAt) {
throw new Error("Target publisher not found");
}
}
if (toUser._id === args.actorUserId) throw new Error("Cannot transfer to yourself");
const activePending = await getActivePendingTransferForSkill(ctx, args.skillId, now);
if (activePending) throw new Error("A transfer is already pending for this skill");
@@ -153,8 +128,6 @@ export const requestTransferInternal = internalMutation({
skillId: skill._id,
fromUserId: args.actorUserId,
toUserId: toUser._id,
fromPublisherId: skill.ownerPublisherId,
toPublisherId: args.toPublisherId,
status: "pending",
message: message || undefined,
requestedAt: now,
@@ -182,7 +155,6 @@ export const acceptTransferInternal = internalMutation({
args: {
actorUserId: v.id("users"),
transferId: v.id("skillOwnershipTransfers"),
publisherId: v.optional(v.id("publishers")),
},
handler: async (ctx, args) => {
const now = Date.now();
@@ -195,45 +167,19 @@ export const acceptTransferInternal = internalMutation({
now,
});
await validateTransferAcceptPermission(ctx, {
actorUserId: args.actorUserId,
toUserId: transfer.toUserId ?? undefined,
toPublisherId: transfer.toPublisherId,
});
const skill = await ctx.db.get(transfer.skillId);
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
const ownerChanged = transfer.fromPublisherId
? skill.ownerPublisherId !== transfer.fromPublisherId
: skill.ownerUserId !== transfer.fromUserId;
if (ownerChanged) {
if (skill.ownerUserId !== transfer.fromUserId) {
await ctx.db.patch(transfer._id, { status: "cancelled", respondedAt: now });
throw new Error("Transfer is no longer valid");
}
// Determine target publisher: sender's choice > recipient override > personal
// When the sender specified a target publisher, honor it unconditionally
let targetPublisherId: Id<"publishers">;
if (transfer.toPublisherId) {
targetPublisherId = transfer.toPublisherId;
} else if (args.publisherId) {
await validateTransferAcceptPermission(ctx, {
actorUserId: args.actorUserId,
toPublisherId: args.publisherId,
});
targetPublisherId = args.publisherId;
} else {
const newPublisher = await ensurePersonalPublisherForUser(ctx, newOwner);
if (!newPublisher) throw new Error("Failed to resolve publisher for new owner");
targetPublisherId = newPublisher._id;
}
const newPublisher = await ensurePersonalPublisherForUser(ctx, newOwner);
if (!newPublisher) throw new Error("Failed to resolve publisher for new owner");
// For org-targeted transfers, ownerUserId is set to whichever admin accepts,
// not necessarily the original toUserId. The toUserHandle on the request just
// routes the transfer — actual ownership reflects who acted on it.
await ctx.db.patch(skill._id, {
ownerUserId: args.actorUserId,
ownerPublisherId: targetPublisherId,
ownerPublisherId: newPublisher._id,
updatedAt: now,
});
@@ -244,16 +190,12 @@ export const acceptTransferInternal = internalMutation({
for (const alias of aliases) {
await ctx.db.patch(alias._id, {
ownerUserId: args.actorUserId,
ownerPublisherId: targetPublisherId,
ownerPublisherId: newPublisher._id,
updatedAt: now,
});
}
await ctx.db.patch(transfer._id, {
status: "accepted",
respondedAt: now,
toUserId: args.actorUserId,
});
await ctx.db.patch(transfer._id, { status: "accepted", respondedAt: now });
await ctx.db.insert("auditLogs", {
actorUserId: args.actorUserId,
@@ -287,12 +229,6 @@ export const rejectTransferInternal = internalMutation({
now,
});
await validateTransferAcceptPermission(ctx, {
actorUserId: args.actorUserId,
toUserId: transfer.toUserId ?? undefined,
toPublisherId: transfer.toPublisherId,
});
await ctx.db.patch(transfer._id, { status: "rejected", respondedAt: now });
await ctx.db.insert("auditLogs", {
actorUserId: args.actorUserId,
@@ -323,15 +259,6 @@ export const cancelTransferInternal = internalMutation({
now,
});
// For org-owned transfers, always verify actor still has admin/owner role
if (transfer.fromPublisherId) {
await validateTransferOwnership(ctx, {
ownerUserId: transfer.fromUserId,
ownerPublisherId: transfer.fromPublisherId,
actorUserId: args.actorUserId,
});
}
await ctx.db.patch(transfer._id, { status: "cancelled", respondedAt: now });
await ctx.db.insert("auditLogs", {
actorUserId: args.actorUserId,
@@ -352,63 +279,28 @@ export const listIncomingInternal = internalQuery({
const now = Date.now();
await requireActiveUserById(ctx, args.userId);
// Query transfers directed at this user personally
const userTransfers = await ctx.db
const transfers = await ctx.db
.query("skillOwnershipTransfers")
.withIndex("by_to_user_status", (q) => q.eq("toUserId", args.userId).eq("status", "pending"))
.collect();
// Query transfers directed at orgs where this user is an admin or owner
const memberships = await ctx.db
.query("publisherMembers")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
const adminPublisherIds = memberships
.filter((m) => m.role === "owner" || m.role === "admin")
.map((m) => m.publisherId);
const orgTransferArrays = await Promise.all(
adminPublisherIds.map((publisherId) =>
ctx.db
.query("skillOwnershipTransfers")
.withIndex("by_to_publisher_status", (q) =>
q.eq("toPublisherId", publisherId).eq("status", "pending"),
)
.collect(),
),
);
const orgTransfers = orgTransferArrays.flat();
// Merge and deduplicate by transfer ID
const seen = new Set<string>();
const allTransfers: TransferDoc[] = [];
for (const t of [...userTransfers, ...orgTransfers]) {
if (!seen.has(t._id)) {
seen.add(t._id);
allTransfers.push(t);
}
}
const results: Array<{
type: "skill";
_id: Id<"skillOwnershipTransfers">;
skill: { _id: Id<"skills">; slug: string; displayName: string };
fromUser: { _id: Id<"users">; handle: string | null; displayName: string | null };
toPublisherId?: Id<"publishers">;
message: string | undefined;
requestedAt: number;
expiresAt: number;
}> = [];
for (const transfer of allTransfers) {
if (isTransferExpired(transfer, now)) continue;
for (const transfer of transfers) {
if (isExpired(transfer, now)) continue;
const skill = await ctx.db.get(transfer.skillId);
if (!skill || skill.softDeletedAt) continue;
const fromUser = await ctx.db.get(transfer.fromUserId);
if (!fromUser || fromUser.deletedAt || fromUser.deactivatedAt) continue;
results.push({
type: "skill" as const,
_id: transfer._id,
skill: { _id: skill._id, slug: skill.slug, displayName: skill.displayName },
fromUser: {
@@ -416,7 +308,6 @@ export const listIncomingInternal = internalQuery({
handle: fromUser.handle ?? null,
displayName: fromUser.displayName ?? null,
},
toPublisherId: transfer.toPublisherId ?? undefined,
message: transfer.message,
requestedAt: transfer.requestedAt,
expiresAt: transfer.expiresAt,
@@ -441,41 +332,29 @@ export const listOutgoingInternal = internalQuery({
.collect();
const results: Array<{
type: "skill";
_id: Id<"skillOwnershipTransfers">;
skill: { _id: Id<"skills">; slug: string; displayName: string };
toUser?: { _id: Id<"users">; handle: string | null; displayName: string | null };
toPublisherId?: Id<"publishers">;
toUser: { _id: Id<"users">; handle: string | null; displayName: string | null };
message: string | undefined;
requestedAt: number;
expiresAt: number;
}> = [];
for (const transfer of transfers) {
if (isTransferExpired(transfer, now)) continue;
if (isExpired(transfer, now)) continue;
const skill = await ctx.db.get(transfer.skillId);
if (!skill || skill.softDeletedAt) continue;
let toUser:
| { _id: Id<"users">; handle: string | null; displayName: string | null }
| undefined;
if (transfer.toUserId) {
const tu = await ctx.db.get(transfer.toUserId);
if (tu && !tu.deletedAt && !tu.deactivatedAt) {
toUser = {
_id: tu._id,
handle: tu.handle ?? null,
displayName: tu.displayName ?? null,
};
}
}
const toUser = await ctx.db.get(transfer.toUserId);
if (!toUser || toUser.deletedAt || toUser.deactivatedAt) continue;
results.push({
type: "skill" as const,
_id: transfer._id,
skill: { _id: skill._id, slug: skill.slug, displayName: skill.displayName },
toUser,
toPublisherId: transfer.toPublisherId ?? undefined,
toUser: {
_id: toUser._id,
handle: toUser.handle ?? null,
displayName: toUser.displayName ?? null,
},
message: transfer.message,
requestedAt: transfer.requestedAt,
expiresAt: transfer.expiresAt,
@@ -486,22 +365,6 @@ export const listOutgoingInternal = internalQuery({
},
});
export const getPendingTransferBySkillInternal = internalQuery({
args: {
skillId: v.id("skills"),
},
handler: async (ctx, args) => {
const now = Date.now();
const transfer = await ctx.db
.query("skillOwnershipTransfers")
.withIndex("by_skill_status", (q) => q.eq("skillId", args.skillId).eq("status", "pending"))
.first();
if (!transfer || isTransferExpired(transfer, now)) return null;
return transfer;
},
});
export const getPendingTransferBySkillAndUserInternal = internalQuery({
args: {
skillId: v.id("skills"),
@@ -515,7 +378,7 @@ export const getPendingTransferBySkillAndUserInternal = internalQuery({
.filter((q) => q.eq(q.field("toUserId"), args.toUserId))
.first();
if (!transfer || isTransferExpired(transfer, now)) return null;
if (!transfer || isExpired(transfer, now)) return null;
return transfer;
},
});
@@ -533,7 +396,7 @@ export const getPendingTransferBySkillAndFromUserInternal = internalQuery({
.filter((q) => q.eq(q.field("fromUserId"), args.fromUserId))
.first();
if (!transfer || isTransferExpired(transfer, now)) return null;
if (!transfer || isExpired(transfer, now)) return null;
return transfer;
},
});
+72
View File
@@ -35,6 +35,12 @@ const getBySlugHandler = (
image: string | null;
bio?: string | null;
} | null;
latestVersion?: {
files?: Array<{
path: string;
contentType?: string;
}>;
} | null;
} | null
>
)._handler;
@@ -171,4 +177,70 @@ describe("skills.getBySlug", () => {
expect(result).toBeNull();
});
it("normalizes misleading file MIME types in public version metadata", async () => {
const ctx = makeCtx({
skill: {
_id: "skills:1",
_creationTime: 1,
slug: "demo",
displayName: "Demo",
summary: "Public demo skill",
ownerUserId: "users:1",
canonicalSkillId: undefined,
forkOf: undefined,
latestVersionId: "skillVersions:1",
tags: {},
stats: {
downloads: 10,
installsCurrent: 2,
installsAllTime: 5,
stars: 3,
versions: 1,
comments: 0,
},
createdAt: 1,
updatedAt: 2,
moderationStatus: "active",
moderationFlags: undefined,
softDeletedAt: undefined,
},
owner: {
_id: "users:1",
_creationTime: 1,
handle: "demo-owner",
name: "Demo Owner",
displayName: "Demo Owner",
image: null,
},
latestVersion: {
_id: "skillVersions:1",
_creationTime: 2,
skillId: "skills:1",
version: "1.0.0",
fingerprint: "abc",
changelog: "",
changelogSource: "user",
files: [
{
path: "src/index.ts",
size: 10,
sha256: "deadbeef",
contentType: "video/mp2t",
},
],
createdBy: "users:1",
createdAt: 2,
},
});
const result = await getBySlugHandler(ctx, { slug: "demo" } as never);
expect(result?.latestVersion?.files).toEqual([
expect.objectContaining({
path: "src/index.ts",
contentType: "application/typescript",
}),
]);
});
});
+10 -1
View File
@@ -1,4 +1,5 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import { normalizeTextContentType } from "clawhub-schema";
import { getPage, type IndexKey, paginator } from "convex-helpers/server/pagination";
import { paginationOptsValidator } from "convex/server";
import { ConvexError, v, type Value } from "convex/values";
@@ -83,6 +84,7 @@ import {
extractDigestFields,
upsertSkillSearchDigest,
} from "./lib/skillSearchDigest";
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
import schema from "./schema";
export { publishVersionForUser } from "./lib/skillPublish";
@@ -757,6 +759,7 @@ 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) {
@@ -1229,7 +1232,7 @@ function toPublicSkillVersion(
path: file.path,
size: file.size,
sha256: file.sha256,
contentType: file.contentType,
contentType: normalizeTextContentType(file.path, file.contentType),
})),
parsed: version.parsed
? {
@@ -2528,6 +2531,7 @@ 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);
@@ -4254,6 +4258,7 @@ 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);
}
@@ -4365,6 +4370,7 @@ 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;
@@ -5304,6 +5310,7 @@ 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);
@@ -5342,6 +5349,7 @@ 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) {
@@ -6422,6 +6430,7 @@ 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);
}
}
+74 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { getSoulBySlugInternal, insertVersion } from "./souls";
import { getSoulBySlugInternal, insertVersion, list } from "./souls";
type WrappedHandler<TArgs> = {
_handler: (ctx: unknown, args: TArgs) => Promise<unknown>;
@@ -10,6 +10,7 @@ const insertVersionHandler = (insertVersion as unknown as WrappedHandler<Record<
const getSoulBySlugInternalHandler = (
getSoulBySlugInternal as unknown as WrappedHandler<{ slug: string }>
)._handler;
const listHandler = (list as unknown as WrappedHandler<{ ownerUserId?: string; limit?: number }>)._handler;
describe("souls.insertVersion", () => {
it("throws a soul-specific ownership error for non-owners", async () => {
@@ -139,3 +140,75 @@ describe("souls.insertVersion", () => {
);
});
});
describe("souls.list", () => {
it("uses the active browse index and only takes the requested limit", async () => {
let requestedIndex: string | null = null;
let requestedSoftDeletedAt: number | undefined;
let requestedLimit: number | null = null;
const result = await listHandler(
{
db: {
query: vi.fn((table: string) => {
if (table !== "souls") throw new Error(`unexpected table ${table}`);
return {
withIndex: (
name: string,
build:
| ((q: { eq: (field: string, value: undefined) => unknown }) => unknown)
| undefined,
) => {
requestedIndex = name;
const q = {
eq: (field: string, value: undefined) => {
if (field !== "softDeletedAt") throw new Error(`unexpected field ${field}`);
requestedSoftDeletedAt = value;
return q;
},
};
build?.(q);
return {
order: () => ({
take: async (limit: number) => {
requestedLimit = limit;
return [
{
_id: "souls:1",
_creationTime: 1,
slug: "demo-soul",
displayName: "Demo Soul",
summary: "A demo soul",
ownerUserId: "users:owner",
ownerPublisherId: undefined,
latestVersionId: undefined,
tags: {},
softDeletedAt: undefined,
stats: { downloads: 1, stars: 2, versions: 3, comments: 4 },
createdAt: 1,
updatedAt: 2,
},
];
},
}),
};
},
};
}),
},
} as never,
{ limit: 7 } as never,
);
expect(requestedIndex).toBe("by_active_updated");
expect(requestedSoftDeletedAt).toBeUndefined();
expect(requestedLimit).toBe(7);
expect(result).toEqual([
expect.objectContaining({
_id: "souls:1",
slug: "demo-soul",
displayName: "Demo Soul",
}),
]);
});
});
+2 -3
View File
@@ -138,11 +138,10 @@ export const list = query({
}
const entries = await ctx.db
.query("souls")
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
.order("desc")
.take(limit * 5);
.take(limit);
return entries
.filter((soul) => !soul.softDeletedAt)
.slice(0, limit)
.map((soul) => toPublicSoul(soul))
.filter((soul): soul is NonNullable<typeof soul> => Boolean(soul));
},
+75
View File
@@ -1075,6 +1075,81 @@ describe("users.list", () => {
expect(result.items[0]?.handle).toBe("alice");
});
it("includes an exact older handle match outside the bounded scan", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const users = [
...Array.from({ length: 500 }, (_value, index) => ({
_id: `users:recent-${index}`,
_creationTime: 10_000 - index,
handle: `recent-${index}`,
role: "user",
})),
{ _id: "users:older", _creationTime: 1, handle: "alice", role: "user" },
];
const { ctx, take, collect } = makeListCtx(users);
const listHandler = (
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
)._handler;
const result = (await listHandler(ctx, { limit: 50, search: "alice" })) as {
items: Array<Record<string, unknown>>;
total: number;
};
expect(take).toHaveBeenCalledWith(500);
expect(collect).not.toHaveBeenCalled();
expect(result.total).toBe(1);
expect(result.items[0]?._id).toBe("users:older");
});
it("includes an exact personal publisher handle match without a full collect", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const users = [{ _id: "users:1", _creationTime: 2, handle: "alice", role: "user" }];
const { ctx, take, collect } = makeListCtx(users, {
publishersByHandle: {
lmlukef: {
_id: "publishers:lmlukef",
kind: "user",
handle: "lmlukef",
linkedUserId: "users:owner",
},
},
usersById: {
"users:owner": {
_id: "users:owner",
_creationTime: 1,
handle: "luke",
name: "different-gh-login",
displayName: "Luke",
role: "user",
},
},
});
const listHandler = (
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
)._handler;
const result = (await listHandler(ctx, { limit: 50, search: "lmLukeF" })) as {
items: Array<Record<string, unknown>>;
total: number;
};
expect(take).toHaveBeenCalledWith(500);
expect(collect).not.toHaveBeenCalled();
expect(result.total).toBe(1);
expect(result.items[0]).toMatchObject({
_id: "users:owner",
handle: "luke",
displayName: "Luke",
});
});
it("clamps large limit and search scan size", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:admin",
+58 -20
View File
@@ -1,22 +1,17 @@
import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx } from "./_generated/server";
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,
@@ -301,9 +296,7 @@ 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);
}
@@ -393,6 +386,23 @@ 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;
@@ -403,13 +413,7 @@ function computeUserSearchScanLimit(limit: number) {
}
async function queryUsersForAdminList(
ctx: {
db: {
query: (table: "users") => {
order: (order: "desc") => { take: (n: number) => Promise<Doc<"users">[]> };
};
};
},
ctx: Pick<QueryCtx, "db">,
args: { limit: number; search?: string; exactUserId?: Id<"users"> },
) {
const normalizedSearch = normalizeSearchQuery(args.search);
@@ -431,6 +435,27 @@ 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);
}
@@ -442,6 +467,20 @@ 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) => {
@@ -860,8 +899,7 @@ 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 -5
View File
@@ -62,6 +62,9 @@ When no proxy variable is set, behavior is unchanged (direct connections).
Stores your API token + cached registry URL.
- macOS: `~/Library/Application Support/clawhub/config.json`
- Linux/XDG: `$XDG_CONFIG_HOME/clawhub/config.json` or `~/.config/clawhub/config.json`
- Windows: `%APPDATA%\\clawhub\\config.json`
- Legacy fallback: if `clawhub/config.json` does not exist yet but `clawdhub/config.json` does, the CLI reuses the legacy path
- override: `CLAWHUB_CONFIG_PATH` (legacy `CLAWDHUB_CONFIG_PATH`)
## Commands
@@ -177,15 +180,13 @@ Stores your API token + cached registry URL.
### `transfer`
- Skill ownership transfer workflow.
- Supports user-to-user, user-to-org, org-to-user, and org-to-org transfers.
- Ownership transfer workflow.
- Subcommands:
- `transfer request <slug> <handle> [--message "..."] [--publisher @org] [--yes]`
- `transfer request <slug> <handle> [--message "..."] [--yes]`
- `transfer list [--outgoing]`
- `transfer accept <slug> [--publisher @org] [--yes]`
- `transfer accept <slug> [--yes]`
- `transfer reject <slug> [--yes]`
- `transfer cancel <slug> [--yes]`
- `--publisher @org`: on request, targets the transfer to an org (recipient must be org admin to accept). On accept, assigns ownership to the org instead of the accepting user's personal publisher.
- Endpoints:
- `POST /api/v1/skills/{slug}/transfer`
- `POST /api/v1/skills/{slug}/transfer/accept`
+2 -19
View File
@@ -478,33 +478,16 @@ Notes:
### Transfer ownership endpoints
Transfers support user-to-user, user-to-org, org-to-user, and org-to-org flows for skills. Org-targeted transfers require the actor to hold `admin` or `owner` role on the relevant publisher.
#### Skill transfers
- `POST /api/v1/skills/{slug}/transfer`
- Body: `{ "toUserHandle": "target_handle", "message": "optional", "toPublisherHandle": "optional_org_handle" }`
- When `toPublisherHandle` is provided, the transfer targets the org. The recipient (or any org admin) accepts on behalf of the org.
- Body: `{ "toUserHandle": "target_handle", "message": "optional" }`
- Response: `{ "ok": true, "transferId": "skillOwnershipTransfers:...", "toUserHandle": "target_handle", "expiresAt": 1730000000000 }`
- `POST /api/v1/skills/{slug}/transfer/accept`
- Optional body: `{ "publisherHandle": "org_handle" }` — assign the skill to an org instead of the accepting user's personal publisher.
- `POST /api/v1/skills/{slug}/transfer/reject`
- `POST /api/v1/skills/{slug}/transfer/cancel`
- Response (accept/reject/cancel): `{ "ok": true, "skillSlug": "demo-skill?" }`
#### Transfer listing
- `GET /api/v1/transfers/incoming`
- `GET /api/v1/transfers/outgoing`
- Returns skill transfers, sorted by `requestedAt` descending.
- Response shape: `{ "transfers": [{ "_id": "...", "type": "skill", "skill": { "slug": "demo", "displayName": "Demo" }, "fromUser"|"toUser": { "handle": "..." }, "message": "...", "requestedAt": 0, "expiresAt": 0 }] }`
#### Transfer rules
- Pending transfers expire after 7 days.
- Only one pending transfer per skill at a time.
- Ownership is re-validated at accept time; if ownership changed since the request, the transfer is auto-cancelled.
- Org transfers require `admin` or `owner` role on the source/target publisher.
- Response shape: `{ "transfers": [{ "_id": "...", "skill": { "slug": "demo", "displayName": "Demo" }, "fromUser"|"toUser": { "handle": "..." }, "message": "...", "requestedAt": 0, "expiresAt": 0 }] }`
### `POST /api/v1/users/ban`
+9 -6
View File
@@ -325,9 +325,12 @@ Semantics:
## Transfer Model
Transfers support publisher-based flows for both skills and packages:
Current transfers are user-to-user only. That is too narrow.
New transfer target should be a publisher.
Support:
- user publisher -> user publisher
- user publisher -> org publisher
- org publisher -> user publisher
- org publisher -> org publisher
@@ -336,12 +339,12 @@ Transfer acceptance rule:
- actor must have `owner` or `admin` on target publisher
Audit records:
Audit should record:
- actor user id
- source publisher id (`fromPublisherId`)
- target publisher id (`toPublisherId`)
- resource id (skill or package)
- source publisher id
- target publisher id
- resource id
## Search Digest Changes
+6
View File
@@ -46,6 +46,8 @@
"@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",
@@ -58,6 +60,7 @@
"@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",
@@ -67,6 +70,8 @@
"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",
@@ -77,6 +82,7 @@
"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"
+6 -1
View File
@@ -27,7 +27,12 @@ clawhub login --token clh_...
Notes:
- Browser login opens `https://clawhub.ai/cli/auth` and completes via a loopback callback.
- Token stored in `~/Library/Application Support/clawhub/config.json` on macOS (override via `CLAWHUB_CONFIG_PATH`, legacy `CLAWDHUB_CONFIG_PATH`).
- Default config path:
- macOS: `~/Library/Application Support/clawhub/config.json`
- Linux/XDG: `$XDG_CONFIG_HOME/clawhub/config.json` or `~/.config/clawhub/config.json`
- Windows: `%APPDATA%\\clawhub\\config.json`
- Legacy fallback: if `clawhub/config.json` does not exist yet but `clawdhub/config.json` does, the CLI reuses the legacy path.
- Override via `CLAWHUB_CONFIG_PATH` (legacy `CLAWDHUB_CONFIG_PATH`).
## Examples
+4 -6
View File
@@ -503,11 +503,10 @@ const transfer = program.command("transfer").description("Transfer skill ownersh
transfer
.command("request")
.description("Request skill transfer to another user or organization")
.description("Request skill transfer to another user")
.argument("<slug>", "Skill slug")
.argument("<handle>", "Recipient handle (e.g., @username)")
.option("--message <text>", "Optional message for recipient")
.option("--publisher <handle>", "Target org publisher handle")
.option("--yes", "Skip confirmation")
.action(async (slug, handle, options) => {
const opts = await resolveGlobalOpts();
@@ -525,9 +524,8 @@ transfer
transfer
.command("accept")
.description("Accept incoming skill transfer")
.description("Accept incoming transfer for a skill")
.argument("<slug>", "Skill slug")
.option("--publisher <handle>", "Accept to org publisher instead of personal")
.option("--yes", "Skip confirmation")
.action(async (slug, options) => {
const opts = await resolveGlobalOpts();
@@ -536,7 +534,7 @@ transfer
transfer
.command("reject")
.description("Reject incoming skill transfer")
.description("Reject incoming transfer for a skill")
.argument("<slug>", "Skill slug")
.option("--yes", "Skip confirmation")
.action(async (slug, options) => {
@@ -546,7 +544,7 @@ transfer
transfer
.command("cancel")
.description("Cancel outgoing skill transfer")
.description("Cancel outgoing transfer for a skill")
.argument("<slug>", "Skill slug")
.option("--yes", "Skip confirmation")
.action(async (slug, options) => {
@@ -64,10 +64,8 @@ describe("transfer commands", () => {
}),
expect.anything(),
);
const requestArgs = httpMocks.apiRequest.mock.calls[0]?.[1] as {
body?: Record<string, string>;
};
expect(requestArgs.body?.toUserHandle).toBe("alice");
const requestArgs = httpMocks.apiRequest.mock.calls[0]?.[1] as { body?: string };
expect(requestArgs.body).toContain('"toUserHandle":"alice"');
});
it("list calls incoming transfers endpoint", async () => {
+8 -20
View File
@@ -63,7 +63,7 @@ export async function cmdTransferRequest(
opts: GlobalOpts,
slugArg: string,
toHandleArg: string,
options: ConfirmOptions & { message?: string; publisher?: string },
options: ConfirmOptions & { message?: string },
inputAllowed: boolean,
) {
const slug = normalizeSlug(slugArg);
@@ -73,7 +73,7 @@ export async function cmdTransferRequest(
const confirmed = await requireYesOrConfirm(
options,
inputAllowed,
`Transfer skill "${slug}" to @${toHandle}? Recipient must accept.`,
`Transfer ${slug} to @${toHandle}? Recipient must accept.`,
);
if (!confirmed) return;
@@ -82,21 +82,16 @@ export async function cmdTransferRequest(
const spinner = createSpinner(`Requesting transfer of ${slug} to @${toHandle}`);
try {
const body: Record<string, string | undefined> = {
toUserHandle: toHandle,
message: options.message,
};
if (options.publisher) {
body.toPublisherHandle = options.publisher.replace(/^@+/, "");
}
const result = await apiRequest(
registry,
{
method: "POST",
path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}/transfer`,
token,
body,
body: JSON.stringify({
toUserHandle: toHandle,
message: options.message,
}),
},
ApiV1TransferRequestResponseSchema,
);
@@ -155,12 +150,11 @@ export async function cmdTransferList(opts: GlobalOpts, options: { outgoing?: bo
async function runTransferDecision(
opts: GlobalOpts,
slugArg: string,
options: ConfirmOptions & { publisher?: string },
options: ConfirmOptions,
inputAllowed: boolean,
spec: DecisionSpec,
) {
const slug = normalizeSlug(slugArg);
const confirmed = await requireYesOrConfirm(
options,
inputAllowed,
@@ -173,18 +167,12 @@ async function runTransferDecision(
const spinner = createSpinner(`${spec.progress} transfer of ${slug}`);
try {
const body: Record<string, string> | undefined =
spec.action === "accept" && options.publisher
? { publisherHandle: options.publisher.replace(/^@+/, "") }
: undefined;
const result = await apiRequest(
registry,
{
method: "POST",
path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}/transfer/${spec.action}`,
token,
...(body ? { body } : {}),
},
ApiV1TransferDecisionResponseSchema,
);
@@ -200,7 +188,7 @@ async function runTransferDecision(
export function cmdTransferAccept(
opts: GlobalOpts,
slugArg: string,
options: ConfirmOptions & { publisher?: string },
options: ConfirmOptions,
inputAllowed: boolean,
) {
return runTransferDecision(opts, slugArg, options, inputAllowed, DECISION_SPECS.accept);
+9
View File
@@ -57,6 +57,14 @@ export const PackageVerificationSummarySchema = type({
});
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
export const PackageStatsSchema = type({
downloads: "number",
installs: "number",
stars: "number",
versions: "number",
});
export type PackageStats = (typeof PackageStatsSchema)[inferred];
export const PackageVtAnalysisSchema = type({
status: "string",
verdict: "string?",
@@ -188,6 +196,7 @@ export const ApiV1PackageResponseSchema = type({
compatibility: PackageCompatibilitySchema.or("null").optional(),
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
verification: PackageVerificationSummarySchema.or("null").optional(),
stats: PackageStatsSchema.optional(),
}).or("null"),
owner: type({
handle: "string|null",
+13
View File
@@ -46,6 +46,13 @@ export declare const PackageVerificationSummarySchema: import("arktype/internal/
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
}, {}>;
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
export declare const PackageStatsSchema: import("arktype/internal/variants/object.ts").ObjectType<{
downloads: number;
installs: number;
stars: number;
versions: number;
}, {}>;
export type PackageStats = (typeof PackageStatsSchema)[inferred];
export declare const PackageVtAnalysisSchema: import("arktype/internal/variants/object.ts").ObjectType<{
status: string;
checkedAt: number;
@@ -261,6 +268,12 @@ export declare const ApiV1PackageResponseSchema: import("arktype/internal/varian
hasProvenance?: boolean | undefined;
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
} | null | undefined;
stats?: {
downloads: number;
installs: number;
stars: number;
versions: number;
} | undefined;
} | null;
owner: {
handle: string | null;
+7
View File
@@ -40,6 +40,12 @@ export const PackageVerificationSummarySchema = type({
hasProvenance: "boolean?",
scanStatus: '"clean"|"suspicious"|"malicious"|"pending"|"not-run"?',
});
export const PackageStatsSchema = type({
downloads: "number",
installs: "number",
stars: "number",
versions: "number",
});
export const PackageVtAnalysisSchema = type({
status: "string",
verdict: "string?",
@@ -151,6 +157,7 @@ export const ApiV1PackageResponseSchema = type({
compatibility: PackageCompatibilitySchema.or("null").optional(),
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
verification: PackageVerificationSummarySchema.or("null").optional(),
stats: PackageStatsSchema.optional(),
}).or("null"),
owner: type({
handle: "string|null",
File diff suppressed because one or more lines are too long
+2
View File
@@ -3,3 +3,5 @@ export declare const TEXT_FILE_EXTENSION_SET: Set<string>;
export declare const TEXT_CONTENT_TYPES: readonly ["application/json", "application/xml", "application/yaml", "application/x-yaml", "application/toml", "application/javascript", "application/typescript", "application/markdown", "image/svg+xml"];
export declare const TEXT_CONTENT_TYPE_SET: Set<string>;
export declare function isTextContentType(contentType: string): boolean;
export declare function guessTextContentType(path: string): string | undefined;
export declare function normalizeTextContentType(path: string, contentType?: string | null): string | undefined;
+35
View File
@@ -53,6 +53,26 @@ const RAW_TEXT_CONTENT_TYPES = [
];
export const TEXT_CONTENT_TYPES = RAW_TEXT_CONTENT_TYPES;
export const TEXT_CONTENT_TYPE_SET = new Set(TEXT_CONTENT_TYPES);
const CANONICAL_TEXT_CONTENT_TYPES = {
md: "text/markdown",
mdx: "text/markdown",
txt: "text/plain",
json: "application/json",
json5: "application/json",
yaml: "application/yaml",
yml: "application/yaml",
toml: "application/toml",
js: "application/javascript",
cjs: "application/javascript",
mjs: "application/javascript",
jsx: "application/javascript",
ts: "application/typescript",
mts: "application/typescript",
cts: "application/typescript",
tsx: "application/typescript",
xml: "application/xml",
svg: "image/svg+xml",
};
export function isTextContentType(contentType) {
if (!contentType)
return false;
@@ -63,4 +83,19 @@ export function isTextContentType(contentType) {
return true;
return TEXT_CONTENT_TYPE_SET.has(normalized);
}
export function guessTextContentType(path) {
const ext = path.trim().toLowerCase().split(".").at(-1) ?? "";
if (!ext || !TEXT_FILE_EXTENSION_SET.has(ext))
return undefined;
return CANONICAL_TEXT_CONTENT_TYPES[ext] ?? "text/plain";
}
export function normalizeTextContentType(path, contentType) {
const normalized = contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
const guessed = guessTextContentType(path);
if (!guessed)
return normalized || undefined;
if (isTextContentType(normalized))
return normalized;
return guessed;
}
//# sourceMappingURL=textFiles.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"textFiles.js","sourceRoot":"","sources":["../src/textFiles.ts"],"names":[],"mappings":"AAAA,MAAM,wBAAwB,GAAG;IAC/B,IAAI;IACJ,KAAK;IACL,KAAK;IACL,MAAM;IACN,OAAO;IACP,MAAM;IACN,KAAK;IACL,MAAM;IACN,IAAI;IACJ,KAAK;IACL,KAAK;IACL,IAAI;IACJ,KAAK;IACL,KAAK;IACL,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,OAAO;IACP,IAAI;IACJ,MAAM;IACN,IAAI;IACJ,KAAK;IACL,GAAG;IACH,GAAG;IACH,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,MAAM;IACN,KAAK;IACL,MAAM;IACN,MAAM;IACN,KAAK;CACG,CAAC;AAEX,MAAM,CAAC,MAAM,oBAAoB,GAAG,wBAAwB,CAAC;AAC7D,MAAM,CAAC,MAAM,uBAAuB,GAAG,IAAI,GAAG,CAAS,oBAAoB,CAAC,CAAC;AAE7E,MAAM,sBAAsB,GAAG;IAC7B,kBAAkB;IAClB,iBAAiB;IACjB,kBAAkB;IAClB,oBAAoB;IACpB,kBAAkB;IAClB,wBAAwB;IACxB,wBAAwB;IACxB,sBAAsB;IACtB,eAAe;CACP,CAAC;AAEX,MAAM,CAAC,MAAM,kBAAkB,GAAG,sBAAsB,CAAC;AACzD,MAAM,CAAC,MAAM,qBAAqB,GAAG,IAAI,GAAG,CAAS,kBAAkB,CAAC,CAAC;AAEzE,MAAM,UAAU,iBAAiB,CAAC,WAAmB;IACnD,IAAI,CAAC,WAAW;QAAE,OAAO,KAAK,CAAC;IAC/B,MAAM,UAAU,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC;IAC5E,IAAI,CAAC,UAAU;QAAE,OAAO,KAAK,CAAC;IAC9B,IAAI,UAAU,CAAC,UAAU,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAC;IAChD,OAAO,qBAAqB,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;AAC/C,CAAC"}
{"version":3,"file":"textFiles.js","sourceRoot":"","sources":["../src/textFiles.ts"],"names":[],"mappings":"AAAA,MAAM,wBAAwB,GAAG;IAC/B,IAAI;IACJ,KAAK;IACL,KAAK;IACL,MAAM;IACN,OAAO;IACP,MAAM;IACN,KAAK;IACL,MAAM;IACN,IAAI;IACJ,KAAK;IACL,KAAK;IACL,IAAI;IACJ,KAAK;IACL,KAAK;IACL,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,OAAO;IACP,IAAI;IACJ,MAAM;IACN,IAAI;IACJ,KAAK;IACL,GAAG;IACH,GAAG;IACH,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,MAAM;IACN,KAAK;IACL,MAAM;IACN,MAAM;IACN,KAAK;CACG,CAAC;AAEX,MAAM,CAAC,MAAM,oBAAoB,GAAG,wBAAwB,CAAC;AAC7D,MAAM,CAAC,MAAM,uBAAuB,GAAG,IAAI,GAAG,CAAS,oBAAoB,CAAC,CAAC;AAE7E,MAAM,sBAAsB,GAAG;IAC7B,kBAAkB;IAClB,iBAAiB;IACjB,kBAAkB;IAClB,oBAAoB;IACpB,kBAAkB;IAClB,wBAAwB;IACxB,wBAAwB;IACxB,sBAAsB;IACtB,eAAe;CACP,CAAC;AAEX,MAAM,CAAC,MAAM,kBAAkB,GAAG,sBAAsB,CAAC;AACzD,MAAM,CAAC,MAAM,qBAAqB,GAAG,IAAI,GAAG,CAAS,kBAAkB,CAAC,CAAC;AAEzE,MAAM,4BAA4B,GAA2B;IAC3D,EAAE,EAAE,eAAe;IACnB,GAAG,EAAE,eAAe;IACpB,GAAG,EAAE,YAAY;IACjB,IAAI,EAAE,kBAAkB;IACxB,KAAK,EAAE,kBAAkB;IACzB,IAAI,EAAE,kBAAkB;IACxB,GAAG,EAAE,kBAAkB;IACvB,IAAI,EAAE,kBAAkB;IACxB,EAAE,EAAE,wBAAwB;IAC5B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,wBAAwB;IAC7B,EAAE,EAAE,wBAAwB;IAC5B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,iBAAiB;IACtB,GAAG,EAAE,eAAe;CACrB,CAAC;AAEF,MAAM,UAAU,iBAAiB,CAAC,WAAmB;IACnD,IAAI,CAAC,WAAW;QAAE,OAAO,KAAK,CAAC;IAC/B,MAAM,UAAU,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC;IAC5E,IAAI,CAAC,UAAU;QAAE,OAAO,KAAK,CAAC;IAC9B,IAAI,UAAU,CAAC,UAAU,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAC;IAChD,OAAO,qBAAqB,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;AAC/C,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,IAAY;IAC/C,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAC9D,IAAI,CAAC,GAAG,IAAI,CAAC,uBAAuB,CAAC,GAAG,CAAC,GAAG,CAAC;QAAE,OAAO,SAAS,CAAC;IAChE,OAAO,4BAA4B,CAAC,GAAG,CAAC,IAAI,YAAY,CAAC;AAC3D,CAAC;AAED,MAAM,UAAU,wBAAwB,CAAC,IAAY,EAAE,WAA2B;IAChF,MAAM,UAAU,GAAG,WAAW,EAAE,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC;IAC7E,MAAM,OAAO,GAAG,oBAAoB,CAAC,IAAI,CAAC,CAAC;IAC3C,IAAI,CAAC,OAAO;QAAE,OAAO,UAAU,IAAI,SAAS,CAAC;IAC7C,IAAI,iBAAiB,CAAC,UAAU,CAAC;QAAE,OAAO,UAAU,CAAC;IACrD,OAAO,OAAO,CAAC;AACjB,CAAC"}
+9
View File
@@ -57,6 +57,14 @@ export const PackageVerificationSummarySchema = type({
});
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
export const PackageStatsSchema = type({
downloads: "number",
installs: "number",
stars: "number",
versions: "number",
});
export type PackageStats = (typeof PackageStatsSchema)[inferred];
export const PackageVtAnalysisSchema = type({
status: "string",
verdict: "string?",
@@ -190,6 +198,7 @@ export const ApiV1PackageResponseSchema = type({
compatibility: PackageCompatibilitySchema.or("null").optional(),
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
verification: PackageVerificationSummarySchema.or("null").optional(),
stats: PackageStatsSchema.optional(),
}).or("null"),
owner: type({
handle: "string|null",
+23 -1
View File
@@ -2,7 +2,12 @@
import { describe, expect, it } from "vitest";
import * as schema from ".";
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from "./textFiles";
import {
guessTextContentType,
isTextContentType,
normalizeTextContentType,
TEXT_FILE_EXTENSION_SET,
} from "./textFiles";
describe("clawhub-schema textFiles", () => {
it("exports text-file extension set", () => {
@@ -16,8 +21,25 @@ describe("clawhub-schema textFiles", () => {
expect(isTextContentType("application/octet-stream")).toBe(false);
});
it("guesses canonical content types for text files", () => {
expect(guessTextContentType("src/index.ts")).toBe("application/typescript");
expect(guessTextContentType("README.md")).toBe("text/markdown");
expect(guessTextContentType("image.png")).toBeUndefined();
});
it("normalizes misleading MIME types for text files", () => {
expect(normalizeTextContentType("src/index.ts", "video/mp2t")).toBe("application/typescript");
expect(normalizeTextContentType("README.md", "text/markdown; charset=utf-8")).toBe(
"text/markdown",
);
expect(normalizeTextContentType("image.png", "image/png")).toBe("image/png");
});
it("re-exports helpers from index", () => {
expect(typeof schema.isTextContentType).toBe("function");
expect(schema.isTextContentType("application/markdown")).toBe(true);
expect(schema.normalizeTextContentType("src/index.ts", "video/mp2t")).toBe(
"application/typescript",
);
});
});
+35
View File
@@ -57,6 +57,27 @@ const RAW_TEXT_CONTENT_TYPES = [
export const TEXT_CONTENT_TYPES = RAW_TEXT_CONTENT_TYPES;
export const TEXT_CONTENT_TYPE_SET = new Set<string>(TEXT_CONTENT_TYPES);
const CANONICAL_TEXT_CONTENT_TYPES: Record<string, string> = {
md: "text/markdown",
mdx: "text/markdown",
txt: "text/plain",
json: "application/json",
json5: "application/json",
yaml: "application/yaml",
yml: "application/yaml",
toml: "application/toml",
js: "application/javascript",
cjs: "application/javascript",
mjs: "application/javascript",
jsx: "application/javascript",
ts: "application/typescript",
mts: "application/typescript",
cts: "application/typescript",
tsx: "application/typescript",
xml: "application/xml",
svg: "image/svg+xml",
};
export function isTextContentType(contentType: string) {
if (!contentType) return false;
const normalized = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
@@ -64,3 +85,17 @@ export function isTextContentType(contentType: string) {
if (normalized.startsWith("text/")) return true;
return TEXT_CONTENT_TYPE_SET.has(normalized);
}
export function guessTextContentType(path: string) {
const ext = path.trim().toLowerCase().split(".").at(-1) ?? "";
if (!ext || !TEXT_FILE_EXTENSION_SET.has(ext)) return undefined;
return CANONICAL_TEXT_CONTENT_TYPES[ext] ?? "text/plain";
}
export function normalizeTextContentType(path: string, contentType?: string | null) {
const normalized = contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
const guessed = guessTextContentType(path);
if (!guessed) return normalized || undefined;
if (isTextContentType(normalized)) return normalized;
return guessed;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 704 B

+45
View File
@@ -0,0 +1,45 @@
<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>

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 169 KiB

+68 -17
View File
@@ -1,12 +1,25 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import { fireEvent, 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 }) => <a href="/">{props.children}</a>,
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(),
}));
vi.mock("@convex-dev/auth/react", () => ({
@@ -16,19 +29,30 @@ vi.mock("@convex-dev/auth/react", () => ({
}),
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => ({
isAuthenticated: false,
isLoading: false,
me: null,
}),
const authStatusMock = vi.fn(() => ({
isAuthenticated: false,
isLoading: false,
me: null,
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => authStatusMock(),
}));
const setThemeMock = vi.fn();
const setModeMock = vi.fn();
vi.mock("../lib/theme", () => ({
applyTheme: vi.fn(),
THEME_OPTIONS: [
{ value: "claw", label: "Claw", description: "" },
{ value: "hub", label: "Hub", description: "" },
],
useThemeMode: () => ({
theme: "hub",
mode: "system",
setMode: vi.fn(),
setTheme: setThemeMock,
setMode: setModeMock,
}),
}));
@@ -56,14 +80,10 @@ vi.mock("../lib/roles", () => ({
vi.mock("../lib/site", () => ({
getClawHubSiteUrl: () => "https://clawhub.ai",
getSiteMode: () => "souls",
getSiteMode: () => siteModeMock(),
getSiteName: () => "OnlyCrabs",
}));
vi.mock("../lib/convexError", () => ({
getUserFacingConvexError: vi.fn(),
}));
vi.mock("../lib/gravatar", () => ({
gravatarUrl: vi.fn(),
}));
@@ -78,15 +98,46 @@ 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,6 +17,7 @@ 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,6 +26,7 @@ const useAuthStatusMock = vi.fn();
const originalFetch = globalThis.fetch;
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useMutation: () => generateUploadUrl,
useAction: () => publishRelease,
useQuery: () => undefined,
@@ -276,6 +277,12 @@ describe("plugins publish route", () => {
expect(screen.getByText(/openclaw\.compat\.pluginApi/i)).toBeTruthy();
expect(screen.getByText(/openclaw\.build\.openclawVersion/i)).toBeTruthy();
const docsLink = screen.getByRole("link", { name: /Plugin Setup and Config/i });
expect(docsLink.getAttribute("href")).toBe(
"https://docs.openclaw.ai/plugins/sdk-setup#package-metadata",
);
expect(docsLink.getAttribute("target")).toBe("_blank");
expect(docsLink.getAttribute("rel")).toBe("noopener noreferrer");
expect(screen.getByRole("button", { name: "Publish" }).getAttribute("disabled")).not.toBeNull();
expect(publishRelease).not.toHaveBeenCalled();
});
+47 -72
View File
@@ -1,99 +1,74 @@
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: { beforeLoad?: unknown }) => ({ __config: config }),
createFileRoute: () => (config: { validateSearch?: unknown; component?: unknown }) => ({
__config: config,
}),
redirect: (options: unknown) => ({ redirect: options }),
Link: "a",
useNavigate: () => vi.fn(),
}));
import { Route } from "../routes/search";
function runBeforeLoad(
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean },
hostname = "clawdhub.com",
) {
function runValidateSearch(search: Record<string, unknown>) {
const route = Route as unknown as {
__config: {
beforeLoad?: (args: {
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean };
location: { url: URL };
}) => void;
validateSearch?: (search: Record<string, unknown>) => unknown;
};
};
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;
const validateSearch = route.__config.validateSearch;
return validateSearch ? validateSearch(search) : {};
}
describe("search route", () => {
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 query", () => {
expect(runValidateSearch({ q: "crab" })).toEqual({
q: "crab",
type: undefined,
});
});
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("validates search with type filter", () => {
expect(runValidateSearch({ q: "crab", type: "skills" })).toEqual({
q: "crab",
type: "skills",
});
});
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("ignores invalid type filter", () => {
expect(runValidateSearch({ q: "crab", type: "invalid" })).toEqual({
q: "crab",
type: undefined,
});
});
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("accepts the users type filter", () => {
expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({
q: "vincent",
type: "users",
});
});
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();
});
});
+53 -194
View File
@@ -6,6 +6,18 @@ 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,
@@ -15,6 +27,7 @@ const useQueryMock = vi.fn();
const getReadmeMock = vi.fn();
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => vi.fn(),
useAction: () => getReadmeMock,
@@ -24,8 +37,8 @@ vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
vi.mock("../components/SkillDiffCard", () => ({
SkillDiffCard: () => <div data-testid="skill-diff-card" />,
vi.mock("../components/SkillCommentsPanel", () => ({
SkillCommentsPanel: () => <div data-testid="skill-comments-panel" />,
}));
describe("SkillDetailPage", () => {
@@ -58,11 +71,8 @@ describe("SkillDetailPage", () => {
return undefined;
});
const { container } = render(<SkillDetailPage slug="weather" />);
// Loading state now renders a skeleton, not text
expect(
container.querySelector('[class*="animate-pulse"], [data-slot="skeleton"]'),
).toBeTruthy();
render(<SkillDetailPage slug="weather" />);
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
expect(screen.queryByText(/Skill not found/i)).toBeNull();
});
@@ -135,174 +145,11 @@ describe("SkillDetailPage", () => {
/>,
);
// With initialData, should render content instead of skeleton
expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy();
expect(screen.queryByText(/Loading skill/i)).toBeNull();
expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0);
expect(screen.getByText(/Get current weather\./i)).toBeTruthy();
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();
expect(screen.getByRole("button", { name: "Files" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Compare" })).toBeNull();
});
it("does not refetch readme when SSR data already matches the latest version", async () => {
@@ -375,7 +222,7 @@ describe("SkillDetailPage", () => {
/>,
);
expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy();
expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0);
expect(screen.getByText(/Get current weather\./i)).toBeTruthy();
expect(getReadmeMock).not.toHaveBeenCalled();
});
@@ -394,17 +241,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,
@@ -417,9 +264,8 @@ describe("SkillDetailPage", () => {
};
});
const { container } = render(<SkillDetailPage slug="weather" redirectToCanonical />);
// Loading state now renders a skeleton, not text
expect(container.querySelector('[class*="animate-pulse"]')).toBeTruthy();
render(<SkillDetailPage slug="weather" redirectToCanonical />);
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
await waitFor(() => {
expect(navigateMock).toHaveBeenCalled();
@@ -524,7 +370,7 @@ describe("SkillDetailPage", () => {
/>,
);
expect(screen.queryByText(/Skill not found/i)).toBeNull();
expect(screen.queryByText(/Loading skill/i)).toBeNull();
expect(screen.getAllByText("Weather").length).toBeGreaterThan(0);
expect(navigateMock).not.toHaveBeenCalled();
});
@@ -634,10 +480,24 @@ 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: {
@@ -666,6 +526,7 @@ 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) => {
@@ -679,9 +540,7 @@ describe("SkillDetailPage", () => {
}),
).toBe(false);
const compareTab = screen.getByRole("tab", { name: /compare/i });
fireEvent.mouseEnter(compareTab);
fireEvent.click(compareTab);
fireEvent.click(screen.getByRole("button", { name: /compare/i }));
await waitFor(() => {
expect(
+22
View File
@@ -17,6 +17,7 @@ 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;
@@ -31,6 +32,7 @@ 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 };
@@ -45,6 +47,14 @@ 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: {
@@ -71,6 +81,18 @@ 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,6 +23,7 @@ vi.mock("@tanstack/react-router", () => ({
}));
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useAction: (...args: unknown[]) => convexReactMocks.useAction(...args),
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
}));
+16 -48
View File
@@ -23,6 +23,7 @@ vi.mock("@tanstack/react-router", () => ({
}));
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useAction: (...args: unknown[]) => convexReactMocks.useAction(...args),
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
}));
@@ -66,7 +67,7 @@ describe("SkillsIndex", () => {
it("renders an empty state when no skills are returned", async () => {
render(<SkillsIndex />);
await act(async () => {});
expect(screen.getByText("No skills match that filter")).toBeTruthy();
expect(screen.getByText("No skills found")).toBeTruthy();
});
it("shows loading state before fetch completes", async () => {
@@ -74,9 +75,9 @@ describe("SkillsIndex", () => {
convexHttpMock.query.mockReturnValue(new Promise(() => {}));
render(<SkillsIndex />);
await act(async () => {});
// Header subtitle shows "Loading skills..."
expect(screen.getAllByText("Loading skills...").length).toBeGreaterThanOrEqual(1);
expect(screen.queryByText("No skills match that filter")).toBeNull();
// Results area shows skeleton or dash while loading
expect(screen.getByText("\u2014")).toBeTruthy();
expect(screen.queryByText("No skills found")).toBeNull();
});
it("shows empty state immediately when search returns no results", async () => {
@@ -91,8 +92,8 @@ describe("SkillsIndex", () => {
});
// Should show empty state, not loading
expect(screen.getByText("No skills match that filter")).toBeTruthy();
expect(screen.queryByText("Loading skills...")).toBeNull();
expect(screen.getByText("No skills found")).toBeTruthy();
expect(screen.queryByText(/Loading skills/)).toBeNull();
});
it("skips list fetch and calls search when query is set", async () => {
@@ -136,7 +137,7 @@ describe("SkillsIndex", () => {
render(<SkillsIndex />);
const input = screen.getByPlaceholderText("Search skills by name, slug, or summary...");
const input = screen.getByPlaceholderText("Search skills...");
await act(async () => {
fireEvent.change(input, { target: { value: "cli-design-framework" } });
await vi.runAllTimersAsync();
@@ -161,7 +162,7 @@ describe("SkillsIndex", () => {
render(<SkillsIndex />);
const input = screen.getByPlaceholderText("Search skills by name, slug, or summary...");
const input = screen.getByPlaceholderText("Search skills...");
await act(async () => {
fireEvent.change(input, { target: { value: "cli-design-framework" } });
await vi.runAllTimersAsync();
@@ -251,9 +252,12 @@ describe("SkillsIndex", () => {
await vi.runAllTimersAsync();
});
const links = screen.getAllByRole("link");
expect(links[0]?.textContent).toContain("Older High Score");
expect(links[1]?.textContent).toContain("Newer Low Score");
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");
});
it("passes nonSuspiciousOnly to list query when filter is active", async () => {
@@ -288,42 +292,6 @@ 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({
@@ -356,7 +324,7 @@ describe("SkillsIndex", () => {
fireEvent.click(loadMoreButton);
});
expect(screen.getByRole("button", { name: "Load more" }).hasAttribute("disabled")).toBe(true);
expect(screen.getByText(/Loading/)).toBeTruthy();
});
});
+1
View File
@@ -22,6 +22,7 @@ const useAuthStatusMock = vi.fn();
let useActionCallCount = 0;
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => generateUploadUrl,
useAction: () => {
+7 -4
View File
@@ -3,6 +3,7 @@ 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() {
@@ -82,10 +83,12 @@ export function AuthErrorHandler() {
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<ConvexAuthProvider client={convex} shouldHandleCode={false}>
<AuthCodeHandler />
<AuthErrorHandler />
<UserBootstrap />
{children}
<TooltipProvider delayDuration={400}>
<AuthCodeHandler />
<AuthErrorHandler />
<UserBootstrap />
{children}
</TooltipProvider>
</ConvexAuthProvider>
);
}
+120
View File
@@ -0,0 +1,120 @@
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>
);
}
+1 -11
View File
@@ -69,17 +69,7 @@ function DeploymentDriftBannerContent() {
return (
<div
role="alert"
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,
}}
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"
>
Deploy mismatch detected. Frontend expects backend build <code>{drift.expectedBuildSha}</code>{" "}
but Convex reports <code>{drift.actualBuildSha}</code>.
+41 -24
View File
@@ -1,34 +1,51 @@
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="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>
<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>
</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>
);
}
+403 -263
View File
@@ -1,16 +1,21 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { Link } from "@tanstack/react-router";
import { Menu, Monitor, Moon, Plus, Search, Sun } from "lucide-react";
import { useMemo, useRef } from "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 { 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, useThemeMode } from "../lib/theme";
import { applyTheme, THEME_OPTIONS, useThemeMode } from "../lib/theme";
import { startThemeTransition } from "../lib/theme-transition";
import { useAuthError } from "../lib/useAuthError";
import { SignInButton } from "./SignInButton";
import { setAuthError, useAuthError } from "../lib/useAuthError";
import { useAuthStatus } from "../lib/useAuthStatus";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Button } from "./ui/button";
import {
DropdownMenu,
@@ -19,309 +24,444 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} 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 { signOut } = useAuthActions();
const { mode, setMode } = useThemeMode();
const { signIn, signOut } = useAuthActions();
const { theme, mode, setMode, setTheme } = 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 setTheme = (next: "system" | "light" | "dark") => {
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") => {
startThemeTransition({
nextTheme: next,
currentTheme: mode,
setTheme: (value) => {
const nextMode = value as "system" | "light" | "dark";
applyTheme(nextMode);
applyTheme(nextMode, theme);
setMode(nextMode);
},
context: { element: toggleRef.current },
});
};
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}
</>
);
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);
};
return (
<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>
<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">
<SheetTitle>{siteName}</SheetTitle>
<SheetDescription>
Browse sections, switch theme, and access account actions.
</SheetDescription>
</SheetHeader>
<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");
<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);
}}
aria-label="Theme mode"
>
<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>
<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>
</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>
{/* 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"
>
<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>
<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>
{/* User menu / Sign in */}
{isAuthenticated && me ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<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)}
>
<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">
<button
type="button"
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)]"
className="theme-cycle-button theme-cycle-button-family"
onClick={cycleThemeFamily}
aria-label={`Cycle theme family. Current: ${themeLabel}`}
title={`Theme family: ${themeLabel}`}
>
<Avatar className="h-7 w-7">
{avatar && (
<AvatarImage src={avatar} alt={me.displayName ?? me.name ?? "User avatar"} />
)}
<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>
<ThemeFamilyIcon className="h-4 w-4" aria-hidden="true" />
</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"
<button
type="button"
className="theme-cycle-button theme-cycle-button-mode"
onClick={cycleThemeMode}
aria-label={`Cycle theme mode. Current: ${mode}`}
title={`Theme mode: ${mode}`}
>
{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>
</div>
) : null}
<SignInButton
variant="primary"
size="sm"
disabled={isLoading}
<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`}
>
<span>Sign in</span>
<span className="hidden text-white/70 sm:inline">with GitHub</span>
</SignInButton>
</>
)}
<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>
)}
<span className="mono">@{handle}</span>
<span className="user-menu-chevron"></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="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>
{/* 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-[color:var(--accent)] text-white shadow-sm"
? "bg-accent text-accent-fg shadow-sm"
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
role="tab"
+63
View File
@@ -0,0 +1,63 @@
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>
);
}
+9 -1
View File
@@ -8,6 +8,9 @@ import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card } from "./ui/card";
const OPENCLAW_PLUGIN_PACKAGE_METADATA_DOCS_URL =
'https://docs.openclaw.ai/plugins/sdk-setup#package-metadata';
export function PackageSourceChooser(props: {
files: File[];
totalBytes: number;
@@ -155,7 +158,12 @@ export function PackageSourceChooser(props: {
<Badge variant="accent">
Missing required OpenClaw package metadata: {props.codePluginFieldIssues.join(", ")}. Add
these fields to <code>package.json</code> before publishing. See{" "}
<a href="/plugins/sdk-setup#package-metadata" className="underline">
<a
href={OPENCLAW_PLUGIN_PACKAGE_METADATA_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Plugin Setup and Config
</a>
.
+40
View File
@@ -0,0 +1,40 @@
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>
);
}
+11 -28
View File
@@ -1,8 +1,8 @@
import { Link } from "@tanstack/react-router";
import { ShieldCheck } from "lucide-react";
import type { ReactNode } from "react";
import type { PublicSkill } from "../lib/publicUser";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Badge } from "./ui/badge";
import type { PublicSkill } from "../lib/publicUser";
type SkillCardProps = {
skill: PublicSkill;
@@ -12,7 +12,6 @@ type SkillCardProps = {
summaryFallback: string;
meta: ReactNode;
href?: string;
verified?: boolean;
};
export function SkillCard({
@@ -23,7 +22,6 @@ export function SkillCard({
summaryFallback,
meta,
href,
verified,
}: SkillCardProps) {
const owner = encodeURIComponent(String(skill.ownerUserId));
const link = href ?? `/${owner}/${skill.slug}`;
@@ -31,43 +29,28 @@ export function SkillCard({
const hasTags = badges.length || chip || platformLabels?.length;
return (
<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)]"
>
<Link to={link} className="card skill-card">
{hasTags ? (
<div className="flex flex-wrap items-center gap-1.5">
<div className="skill-card-tags">
{badges.map((label) => (
<Badge key={label} variant="default">
<Badge key={label}>
{label}
</Badge>
))}
{chip ? (
<Badge variant="accent" className="text-[0.72rem] px-2.5 py-0.5">
{chip}
</Badge>
) : null}
{chip ? <Badge variant="accent">{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}
<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 className="skill-card-header">
<MarketplaceIcon kind="skill" label={skill.displayName} size="md" />
<h3 className="skill-card-title">{skill.displayName}</h3>
</div>
<p className="skill-card-summary">{skill.summary ?? summaryFallback}</p>
<div className="skill-card-footer">{meta}</div>
</Link>
);
}
+111 -106
View File
@@ -2,19 +2,16 @@ 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 } from "./SkillDetailTabs";
import { SkillDetailTabs, type DetailTab } from "./SkillDetailTabs";
import { SkillMetadataSidebar } from "./SkillMetadataSidebar";
import {
buildSkillHref,
formatConfigSnippet,
@@ -25,8 +22,6 @@ 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;
@@ -38,11 +33,13 @@ type SkillDetailPageProps = {
type SkillFile = Doc<"skillVersions">["files"][number];
function formatReportError(error: unknown) {
if (hasOwnProperty(error, "data")) {
if (error && typeof error === "object" && "data" in error) {
const data = (error as { data?: unknown }).data;
if (typeof data === "string" && data.trim()) return data.trim();
if (
hasOwnProperty(data, "message") &&
data &&
typeof data === "object" &&
"message" in data &&
typeof (data as { message?: unknown }).message === "string"
) {
const message = (data as { message?: string }).message?.trim();
@@ -98,7 +95,7 @@ export function SkillDetailPage({
);
const [tagName, setTagName] = useState("latest");
const [tagVersionId, setTagVersionId] = useState<Id<"skillVersions"> | "">("");
const [activeTab, setActiveTab] = useState<"files" | "compare" | "versions">("files");
const [activeTab, setActiveTab] = useState<DetailTab>("readme");
const [shouldPrefetchCompare, setShouldPrefetchCompare] = useState(false);
const [isReportDialogOpen, setIsReportDialogOpen] = useState(false);
const [reportReason, setReportReason] = useState("");
@@ -215,7 +212,6 @@ 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
@@ -299,13 +295,10 @@ export function SkillDetailPage({
const deleteTag = (tag: string) => {
if (!skill) return;
toast(`Delete tag "${tag}"?`, {
action: {
label: "Delete",
onClick: () => {
void deleteTags({ skillId: skill._id, tags: [tag] });
},
},
if (!window.confirm(`Delete tag "${tag}"?`)) return;
void deleteTags({
skillId: skill._id,
tags: [tag],
});
};
@@ -324,9 +317,9 @@ export function SkillDetailPage({
const submission = await reportSkill({ skillId: skill._id, reason: trimmedReason });
closeReportDialog();
if (submission.reported) {
toast.success("Thanks — your report has been submitted.");
window.alert("Thanks — your report has been submitted.");
} else {
toast.info("You have already reported this skill.");
window.alert("You have already reported this skill.");
}
} catch (error) {
console.error("Failed to report skill", error);
@@ -336,19 +329,19 @@ export function SkillDetailPage({
};
if (isLoadingSkill || wantsCanonicalRedirect) {
return <SkillDetailSkeleton />;
return (
<main className="section">
<Card>
<div className="loading-indicator">Loading skill</div>
</Card>
</main>
);
}
if (result === null || !skill) {
return (
<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 className="section">
<Card>Skill not found.</Card>
</main>
);
}
@@ -356,80 +349,91 @@ export function SkillDetailPage({
const tagEntries = Object.entries(skill.tags ?? {}) as Array<[string, Id<"skillVersions">]>;
return (
<main className="py-10">
<Container>
<div className="flex flex-col gap-6">
<SkillHeader
skill={skill}
owner={owner}
<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}
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}
ownerId={owner?._id ?? null}
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
/>
) : null}
{isOwner && skill ? (
<SkillOwnershipPanel
skillId={skill._id}
slug={skill.slug}
ownerHandle={ownerHandle}
ownerId={owner?._id ?? null}
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
/>
) : null}
<SkillMetadataSidebar
skill={skill}
latestVersion={latestVersion}
owner={owner}
ownerHandle={ownerHandle}
clawdis={clawdis}
osLabels={osLabels}
tagEntries={tagEntries}
isMalwareBlocked={modInfo?.isMalwareBlocked}
isRemoved={modInfo?.isRemoved}
nixPlugin={nixPlugin}
/>
<div className="detail-content-full">
{nixSnippet ? (
<Card>
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Install via Nix
</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>
</h3>
<pre className="hero-install-code mt-2">
{nixSnippet}
</pre>
</Card>
) : null}
{configExample ? (
<Card>
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Config example
</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>
</h3>
<pre className="hero-install-code mt-2">
{configExample}
</pre>
</Card>
) : null}
@@ -452,11 +456,12 @@ export function SkillDetailPage({
<ClientOnly
fallback={
<Card>
<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>
<h2 className="section-title text-[1.2rem] m-0">
Comments
</h2>
<p className="section-subtitle mt-3 mb-0">
Loading comments...
</p>
</Card>
}
>
@@ -467,17 +472,17 @@ export function SkillDetailPage({
/>
</ClientOnly>
</div>
</div>
<SkillReportDialog
isOpen={isAuthenticated && isReportDialogOpen}
isSubmitting={isSubmittingReport}
reportReason={reportReason}
reportError={reportError}
onReasonChange={setReportReason}
onCancel={closeReportDialog}
onSubmit={() => void submitReport()}
/>
</Container>
<SkillReportDialog
isOpen={isAuthenticated && isReportDialogOpen}
isSubmitting={isSubmittingReport}
reportReason={reportReason}
reportError={reportError}
onReasonChange={setReportReason}
onCancel={closeReportDialog}
onSubmit={() => void submitReport()}
/>
</main>
);
}
+83 -38
View File
@@ -1,9 +1,8 @@
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 })),
@@ -15,9 +14,11 @@ const SkillFilesPanel = lazy(() =>
type SkillFile = Doc<"skillVersions">["files"][number];
export type DetailTab = "readme" | "files" | "compare" | "versions";
type SkillDetailTabsProps = {
activeTab: "files" | "compare" | "versions";
setActiveTab: (tab: "files" | "compare" | "versions") => void;
activeTab: DetailTab;
setActiveTab: (tab: DetailTab) => void;
onCompareIntent: () => void;
readmeContent: string | null;
readmeError: string | null;
@@ -46,13 +47,30 @@ export function SkillDetailTabs({
suppressVersionScanResults,
scanResultsSuppressedMessage,
}: SkillDetailTabsProps) {
const compareEnabled = (versions?.length ?? 0) > 1;
return (
<Card>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as typeof activeTab)}>
<TabsList>
<TabsTrigger value="files">Files</TabsTrigger>
<TabsTrigger
value="compare"
<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")}
onMouseEnter={() => {
onCompareIntent();
void import("./SkillDiffCard");
@@ -63,37 +81,64 @@ export function SkillDetailTabs({
}}
>
Compare
</TabsTrigger>
<TabsTrigger value="versions">Versions</TabsTrigger>
</TabsList>
</button>
) : null}
<button
className={`tab-button${activeTab === "versions" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("versions")}
>
Versions
</button>
</div>
<TabsContent value="files">
<Suspense fallback={<Skeleton className="h-40 w-full" />}>
<SkillFilesPanel
versionId={latestVersionId}
readmeContent={readmeContent}
readmeError={readmeError}
latestFiles={latestFiles}
/>
</Suspense>
</TabsContent>
{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="compare">
<Suspense fallback={<Skeleton className="h-40 w-full" />}>
{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>}>
<SkillDiffCard skill={skill} versions={diffVersions ?? []} variant="embedded" />
</Suspense>
</TabsContent>
</div>
) : null}
<TabsContent value="versions">
<SkillVersionsPanel
versions={versions}
nixPlugin={nixPlugin}
skillSlug={skill.slug}
suppressScanResults={suppressVersionScanResults}
suppressedMessage={scanResultsSuppressedMessage}
/>
</TabsContent>
</Tabs>
</Card>
{activeTab === "versions" ? (
<SkillVersionsPanel
versions={versions}
nixPlugin={nixPlugin}
skillSlug={skill.slug}
suppressScanResults={suppressVersionScanResults}
suppressedMessage={scanResultsSuppressedMessage}
/>
) : null}
</div>
);
}
+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("shadow-sm");
expect(screen.getByRole("button", { name: "Inline" }).className).toContain("is-active");
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("shadow-sm");
expect(screen.getByRole("button", { name: "Side-by-side" }).className).toContain("is-active");
});
});
+109 -138
View File
@@ -13,12 +13,9 @@ import {
selectDefaultFilePath,
sortVersionsBySemver,
} from "../lib/diffing";
import { ClientOnly } from "./ClientOnly";
import { Badge } from "./ui/badge";
import { isDarkThemeResolved, onThemeChange } from "../lib/theme";
import { Button } from "./ui/button";
import { Card } from "./ui/card";
import { Label } from "./ui/label";
import { Skeleton } from "./ui/skeleton";
import { ClientOnly } from "./ClientOnly";
type SkillDiffCardProps = {
skill: Doc<"skills">;
@@ -236,15 +233,18 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
useEffect(() => {
if (!monaco || typeof document === "undefined") return;
const observer = new MutationObserver(() => {
applyMonacoTheme(monaco);
});
const syncTheme = () => applyMonacoTheme(monaco);
const observer = new MutationObserver(syncTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
attributeFilter: ["data-theme", "data-theme-family", "data-theme-resolved"],
});
applyMonacoTheme(monaco);
return () => observer.disconnect();
const removeThemeListener = onThemeChange(syncTheme);
syncTheme();
return () => {
observer.disconnect();
removeThemeListener();
};
}, [monaco]);
useEffect(() => {
@@ -277,174 +277,145 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
const fileSelected = Boolean(selectedItem);
const diffOptions = useMemo(() => buildDiffOptions(viewMode), [viewMode]);
const Wrapper = variant === "card" ? Card : "div";
const wrapperClassName = variant === "card" ? "flex flex-col gap-4" : "flex flex-col gap-4";
const containerClass = variant === "card" ? "card diff-card" : "diff-card diff-card-embedded";
return (
<Wrapper className={wrapperClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className={containerClass}>
<div className="diff-header">
<div>
<h2 className="m-0 font-display text-[1.2rem] font-bold text-[color:var(--ink)]">
<h2 className="section-title text-[1.2rem] m-0">
Compare versions
</h2>
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
<p className="section-subtitle m-0">
Inline or side-by-side diff for any file.
</p>
</div>
<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>
{!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}
</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>
{!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-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-meta">
<span>
Left {leftLabel} Right {rightLabel}
</span>
</div>
</>
) : null}
<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>
<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>
) : (
fileDiffItems.map((item) => (
<button
key={item.path}
type="button"
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)]"
}`}
className={`diff-file${item.path === selectedPath ? " is-active" : ""}`}
onClick={() => setSelectedPath(item.path)}
>
<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>
<span className={`diff-pill diff-pill-${item.status}`}>{item.status}</span>
<span className="diff-file-name">{item.path}</span>
</button>
))
)}
</div>
<div className="relative min-h-[300px]">
<div className="diff-view">
{error ? (
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
{error}
</div>
<div className="diff-empty">{error}</div>
) : sizeWarning ? (
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
<div className="diff-empty">
{sizeWarning.side === "left" ? "Left" : "Right"} file exceeds 200KB:{" "}
{sizeWarning.path}
</div>
) : diffUnavailable ? (
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
Publish another version to compare.
</div>
<div className="diff-empty">Publish another version to compare.</div>
) : !selectionReady ? (
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
Select two versions to compare.
</div>
<div className="diff-empty">Select two versions to compare.</div>
) : !fileSelected ? (
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
Select a file to compare.
</div>
<div className="diff-empty">Select a file to compare.</div>
) : (
<ClientOnly fallback={<Skeleton className="h-full w-full" />}>
<ClientOnly fallback={<div className="diff-empty">Preparing diff</div>}>
<DiffEditor
key={`diff-${viewMode}`}
className={`h-full min-h-[400px] w-full ${viewMode === "inline" ? "max-w-full" : ""}`}
className={`diff-monaco diff-monaco-${viewMode}`}
original={leftText}
modified={rightText}
theme={getMonacoThemeName()}
loading={<Skeleton className="h-full w-full" />}
loading={<div className="diff-empty">Loading diff</div>}
options={diffOptions}
/>
{isLoading ? (
<div className="absolute inset-0 flex items-center justify-center bg-[color:var(--surface)]/80">
<Skeleton className="h-8 w-24" />
</div>
) : null}
{isLoading ? <div className="diff-loading">Loading</div> : null}
</ClientOnly>
)}
</div>
</div>
</Wrapper>
</div>
);
}
@@ -472,7 +443,7 @@ function renderOptions(options: VersionOption[]) {
function getMonacoThemeName() {
if (typeof document === "undefined") return "clawhub-light";
return document.documentElement.dataset.theme === "dark" ? "clawhub-dark" : "clawhub-light";
return isDarkThemeResolved() ? "clawhub-dark" : "clawhub-light";
}
function buildDiffOptions(viewMode: "split" | "inline"): DiffEditorProps["options"] {
@@ -508,7 +479,7 @@ function applyMonacoTheme(monaco: NonNullable<ReturnType<typeof useMonaco>>) {
const diffDiagonal = styles.getPropertyValue("--diff-diagonal").trim() || "#22222233";
const background = surface;
const gutter = surfaceMuted;
const isDark = document.documentElement.dataset.theme === "dark";
const isDark = isDarkThemeResolved();
const base = isDark ? "vs-dark" : "vs";
const diffInserted = withAlpha(diffAdded, isDark ? 0.22 : 0.2);
-8
View File
@@ -9,10 +9,6 @@ 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 {
@@ -34,8 +30,6 @@ describe("SkillFilesPanel", () => {
render(
<SkillFilesPanel
versionId={"skillVersions:1" as Id<"skillVersions">}
readmeContent={"# skill"}
readmeError={null}
latestFiles={[makeFile("scripts/run.sh", 10)]}
/>,
);
@@ -68,8 +62,6 @@ describe("SkillFilesPanel", () => {
render(
<SkillFilesPanel
versionId={"skillVersions:1" as Id<"skillVersions">}
readmeContent={"# skill"}
readmeError={null}
latestFiles={[makeFile("a.txt", 5), makeFile("b.txt", 6)]}
/>,
);
+21 -53
View File
@@ -2,23 +2,17 @@ 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);
@@ -91,82 +85,56 @@ export function SkillFilesPanel({
);
return (
<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)]">
<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">
Files
</h3>
<span className="m-0 text-sm text-[color:var(--ink-soft)]">
<span className="section-subtitle m-0">
{latestFiles.length} total
</span>
</div>
<div className="flex max-h-[400px] flex-col overflow-y-auto">
<div className="file-list-body">
{latestFiles.length === 0 ? (
<div className="px-3 py-2 text-sm text-[color:var(--ink-soft)]">
No files available.
</div>
<div className="stat">No files available.</div>
) : (
latestFiles.map((file) => (
<button
key={file.path}
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)]"
className={`file-row file-row-button${
selectedPath === file.path ? " is-active" : ""
}`}
type="button"
onClick={() => handleSelect(file.path)}
aria-current={selectedPath === file.path ? "true" : undefined}
>
<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>
<span className="file-path">{file.path}</span>
<span className="file-meta">{formatBytes(file.size)}</span>
</button>
))
)}
</div>
</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>
<div className="file-viewer">
<div className="file-viewer-header">
<div className="file-path">{selectedPath ?? "Select a file"}</div>
{fileMeta ? (
<span className="ml-2 shrink-0 text-xs text-[color:var(--ink-soft)]">
<span className="file-meta">
{formatBytes(fileMeta.size)} · {fileMeta.sha256.slice(0, 12)}
</span>
) : null}
</div>
<div className="min-h-[200px] p-3">
<div className="file-viewer-body">
{isLoading ? (
<Skeleton className="h-24 w-full" />
<div className="stat">Loading</div>
) : fileError ? (
<div className="text-sm text-[color:var(--ink-soft)]">
Failed to load file: {fileError}
</div>
<div className="stat">Failed to load file: {fileError}</div>
) : fileContent ? (
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed">
{fileContent}
</pre>
<pre className="file-viewer-code">{fileContent}</pre>
) : (
<div className="text-sm text-[color:var(--ink-soft)]">Select a file to preview.</div>
<div className="stat">Select a file to preview.</div>
)}
</div>
</div>
+156 -209
View File
@@ -1,21 +1,14 @@
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 { Link } from "@tanstack/react-router";
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 = {
@@ -117,10 +110,9 @@ export function SkillHeader({
onTagSubmit,
onTagDelete,
tagVersions,
clawdis,
osLabels,
clawdis: _clawdis,
osLabels: _osLabels,
}: SkillHeaderProps) {
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
const formattedStats = formatSkillStatsTriplet(skill.stats);
const suppressScanResults =
!isStaff &&
@@ -134,8 +126,8 @@ export function SkillHeader({
return (
<>
{modInfo?.isPendingScan ? (
<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">
<div className="pending-banner">
<div className="pending-banner-content">
<strong>Security scan in progress</strong>
<p>
Your skill is being scanned by VirusTotal. It will be visible to others once the scan
@@ -145,8 +137,8 @@ export function SkillHeader({
</div>
</div>
) : modInfo?.isMalwareBlocked ? (
<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">
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<strong>Skill blocked malicious content detected</strong>
<p>
ClawHub Security flagged this skill as malicious. Downloads are disabled. Review the
@@ -155,15 +147,15 @@ export function SkillHeader({
</div>
</div>
) : modInfo?.isSuspicious ? (
<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">
<div className="pending-banner pending-banner-warning">
<div className="pending-banner-content">
<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="text-sm text-[color:var(--ink-soft)]">
<p className="pending-banner-appeal">
If you believe this skill has been incorrectly flagged, please{" "}
<a
href="https://github.com/openclaw/clawhub/issues"
@@ -178,142 +170,109 @@ export function SkillHeader({
</div>
</div>
) : modInfo?.isRemoved ? (
<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">
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<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="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">
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<strong>Skill hidden</strong>
<p>This skill is currently hidden and not visible to others.</p>
</div>
</div>
) : null}
<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>
<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>
</>
) : null}
{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>
{canonicalHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
canonical:{" "}
<a href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
</>
) : 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="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">
<div className="skill-hero-sidebar">
<div className="skill-actions">
{isAuthenticated ? (
<button
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"}`}
className={`star-toggle${isStarred ? " is-active" : ""}`}
type="button"
onClick={onToggleStar}
aria-label={isStarred ? "Unstar skill" : "Star skill"}
@@ -322,18 +281,16 @@ export function SkillHeader({
</button>
) : null}
{isAuthenticated ? (
<Button variant="ghost" size="sm" onClick={onOpenReport}>
<Button variant="ghost" size="sm" type="button" onClick={onOpenReport}>
Report
</Button>
) : null}
{isStaff ? (
<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>
<Button asChild size="sm">
<Link to="/management" search={{ skill: skill.slug }}>
Manage
</Link>
</Button>
) : null}
</div>
</div>
@@ -341,13 +298,12 @@ export function SkillHeader({
{/* Security scan — full width below the header columns */}
{suppressScanResults ? (
<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>
<div className="skill-hero-note">{overrideScanMessage}</div>
) : latestVersion?.sha256hash ||
latestVersion?.llmAnalysis ||
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ? (
<div className="flex flex-col gap-2">
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ||
(latestVersion?.capabilityTags?.length ?? 0) > 0 ? (
<div className="skill-hero-scan-row">
<SecurityScanResults
sha256hash={latestVersion?.sha256hash}
vtAnalysis={latestVersion?.vtAnalysis}
@@ -355,77 +311,67 @@ export function SkillHeader({
staticFindings={latestVersion?.staticScan?.findings}
capabilityTags={latestVersion?.capabilityTags}
/>
<p className="text-xs text-[color:var(--ink-soft)]">
<p className="scan-disclaimer">
Like a lobster shell, security has layers review code before you run it.
</p>
</div>
) : null}
{hasPluginBundle ? (
<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 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}
</div>
</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}
{cliHelp ? (
<details className="bundle-section bundle-details">
<summary>CLI help (from plugin)</summary>
<pre className="hero-install-code mono">{cliHelp}</pre>
</details>
) : null}
</div>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 border-t border-[color:var(--line)] pt-4">
<div className="skill-tag-row">
{tagEntries.length === 0 ? (
<span className="m-0 text-sm text-[color:var(--ink-soft)]">No tags yet.</span>
<span className="section-subtitle m-0">
No tags yet.
</span>
) : (
tagEntries.map(([tag, versionId]) => (
<Badge key={tag} className="gap-1.5">
<Badge key={tag}>
{tag}
<span className="text-[0.68rem] opacity-70">
<span className="tag-meta">
v{versionById.get(versionId)?.version ?? versionId}
</span>
{canManage && tag !== "latest" ? (
<button
type="button"
className="ml-0.5 cursor-pointer border-none bg-transparent p-0 text-current opacity-60 hover:opacity-100"
className="tag-delete"
onClick={() => onTagDelete(tag)}
aria-label={`Delete tag ${tag}`}
title={`Delete tag "${tag}"`}
@@ -444,16 +390,16 @@ export function SkillHeader({
event.preventDefault();
onTagSubmit();
}}
className="flex flex-wrap items-end gap-2 border-t border-[color:var(--line)] pt-4"
className="tag-form"
>
<Input
<input
className="search-input"
value={tagName}
onChange={(event) => onTagNameChange(event.target.value)}
placeholder="latest"
className="w-auto max-w-[160px]"
/>
<select
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)]"
className="search-input"
value={tagVersionId ?? ""}
onChange={(event) => onTagVersionChange(event.target.value as Id<"skillVersions">)}
>
@@ -463,12 +409,13 @@ export function SkillHeader({
</option>
))}
</select>
<Button type="submit">Update tag</Button>
<Button type="submit">
Update tag
</Button>
</form>
) : null}
<SkillInstallCard clawdis={clawdis} osLabels={osLabels} />
</Card>
</div>
</>
);
}
+163 -218
View File
@@ -1,12 +1,6 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
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";
import { formatInstallCommand, formatInstallLabel } from "./skillDetailUtils";
type SkillInstallCardProps = {
clawdis: ClawdisSkillMetadata | undefined;
@@ -32,229 +26,180 @@ 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 && !hasLicense) {
if (!hasRuntimeRequirements && !hasInstallSpecs && !hasDependencies && !hasLinks) {
return null;
}
return (
<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>
<div className="skill-hero-content">
<div className="skill-hero-panels">
{hasRuntimeRequirements ? (
<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>
) : 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>
) : 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>
))}
</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>
<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}
{command ? (
<code className="mt-0.5 block font-mono text-xs">{command}</code>
{env.description ? (
<span className="text-ink-soft text-[0.8rem]">
{env.description}
</span>
) : null}
</div>
))}
</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}
</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(", ")}
</div>
) : null}
{command ? <code>{command}</code> : null}
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
);
})}
</div>
</div>
) : null}
{hasLinks ? (
<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>
<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>
) : null}
</div>
</div>
+53
View File
@@ -0,0 +1,53 @@
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
@@ -0,0 +1,121 @@
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>
);
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { Badge } from "./ui/badge";
type LlmAnalysisDimension = {
name: string;
@@ -388,9 +389,9 @@ export function SecurityScanResults({
<div className="scan-findings-title">Capability signals</div>
<div className="scan-capability-tags">
{visibleCapabilityTags.map((tag) => (
<span key={tag} className="tag scan-capability-tag">
<Badge key={tag} className="scan-capability-tag">
{SKILL_CAPABILITY_LABELS[tag] ?? tag}
</span>
</Badge>
))}
</div>
<div className="scan-capability-note">
+8 -12
View File
@@ -1,5 +1,6 @@
import { Link } from "@tanstack/react-router";
import type { ReactNode } from "react";
import { MarketplaceIcon } from "./MarketplaceIcon";
import type { PublicSoul } from "../lib/publicUser";
type SoulCardProps = {
@@ -10,18 +11,13 @@ type SoulCardProps = {
export function SoulCard({ soul, summaryFallback, meta }: SoulCardProps) {
return (
<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 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>
);
}
+88 -1
View File
@@ -1,5 +1,12 @@
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;
@@ -40,7 +47,14 @@ export function UserBadge({
displayName!.toLowerCase() !== handle!.toLowerCase();
const initial = (displayName ?? handle ?? "u").charAt(0).toUpperCase();
return (
// 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 = (
<span className={`user-badge user-badge-${size}`}>
{prefix ? <span className="user-badge-prefix">{prefix}</span> : null}
<span className="user-avatar" aria-hidden="true">
@@ -67,4 +81,77 @@ 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
@@ -0,0 +1,31 @@
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-[1200px]",
size === "narrow" && "max-w-[900px]",
size === "wide" && "max-w-[1400px]",
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-page-max",
size === "narrow" && "max-w-page-narrow",
size === "wide" && "w-full",
className,
)}
{...props}
/>
),
);
Container.displayName = "Container";
@@ -2,7 +2,7 @@ import { Skeleton } from "../ui/skeleton";
export function DashboardSkeleton() {
return (
<div className="mx-auto max-w-[1200px] px-7 py-10">
<div className="mx-auto max-w-page-max 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-[1200px] px-7 py-10">
<div className="mx-auto max-w-page-max px-7 py-10">
{/* Breadcrumb */}
<Skeleton className="mb-6 h-4 w-48" />
+10 -31
View File
@@ -10,37 +10,16 @@ const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
<span
ref={ref}
className={cn(
// 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",
],
// 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",
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 (matches .btn:hover)
"hover:not-disabled:-translate-y-px hover:not-disabled:shadow-[0_10px_20px_rgba(29,26,23,0.12)]",
// Hover lift
"hover:not-disabled:-translate-y-px hover:not-disabled:shadow-hover",
// Variant styles
variant === "default" &&
"border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)]",
variant === "primary" &&
"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)]",
"border border-accent bg-accent/10 text-[color:var(--ink)]",
variant === "destructive" &&
"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",
"border border-status-error-fg/20 bg-status-error-bg text-status-error-fg hover:not-disabled:bg-active-bg",
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-[22px] 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-space-5 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-[rgba(21,24,35,0.42)] p-5 backdrop-blur-[3px]",
"fixed inset-0 z-80 grid place-items-center bg-overlay-bg 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-[0_24px_50px_rgba(18,22,34,0.24)]",
"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",
"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-xl border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
"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)]",
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-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",
"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",
className,
)}
{...props}
+6 -10
View File
@@ -7,16 +7,12 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
ref={ref}
type={type}
className={cn(
// 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)]",
// 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)]",
// Disabled
"disabled:cursor-not-allowed disabled:opacity-60",
className,
+2 -3
View File
@@ -10,9 +10,8 @@ const Label = React.forwardRef<
ref={ref}
className={cn(
// Matches .form-label
"text-[0.74rem] font-bold uppercase tracking-[0.14em]",
"text-[rgba(70,95,113,0.9)]",
"dark:text-[rgba(206,227,238,0.76)]",
"text-fs-xs font-bold uppercase tracking-[0.14em]",
"text-label-fg",
"peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
+7 -8
View File
@@ -14,12 +14,11 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
// 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)]",
// 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)]",
"disabled:cursor-not-allowed disabled:opacity-60",
className,
)}
@@ -69,7 +68,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"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)]",
"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)]",
"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",
@@ -115,7 +114,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"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",
"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",
"focus:bg-[color:var(--surface-muted)] focus:text-[color:var(--ink)]",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,

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