Compare commits

..
Author SHA1 Message Date
Val Alexander d2c856e68a fix: allow publisher id skill routes 2026-04-07 07:41:38 -05:00
Val Alexander 47822918c5 test: satisfy oxlint constructor rule 2026-04-07 07:39:25 -05:00
Val Alexander 74b834b4dd fix: resolve PR #1570 review follow-ups 2026-04-07 07:30:04 -05:00
Val AlexanderandClaude Opus 4.6 77abd47e16 fix(tests): add ConvexReactClient to convex/react mocks
All 6 test files that mock convex/react now include a stub
ConvexReactClient class so src/convex/client.ts can import
without crashing at module load time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 07:05:37 -05:00
Val AlexanderandClaude Opus 4.6 1f7de02144 fix(ci): provide dummy VITE_CONVEX_URL for test and coverage steps
Tests that import src/convex/client.ts crash at module load time
because getRequiredRuntimeEnv("VITE_CONVEX_URL") throws when the
env var is missing. Set a non-resolvable dummy URL for CI since
unit tests don't make real Convex calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 07:00:24 -05:00
Val AlexanderandClaude Opus 4.6 c8a515a785 feat: add subtle icons to Skills, Plugins, Souls navbar tabs
Each primary nav tab gets a monochrome lucide icon (Wrench, Plug, Ghost)
at 50% opacity for scanability. Icons brighten to 85% on hover and 100%
when active. Secondary tabs unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 06:51:15 -05:00
Val AlexanderandClaude Opus 4.6 cbacfb4812 fix: align plugin detail page width with skills page
Replace <Container> wrapper with .section class so plugin detail uses
the same max-width token (--page-max) and padding as skill detail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 05:51:13 -05:00
Val AlexanderandClaude Opus 4.6 84a48f28f9 fix: avoid CSS named color 'gray' causing Tailwind v4 token error
The CSS minifier converts #808080 to the named color 'gray', which
Tailwind v4 rejects as an illegal theme token value. Use #818181
instead — visually identical but not a named CSS color.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 05:30:50 -05:00
Val AlexanderandClaude Opus 4.6 ceebcc5bca Update branding to lobster logo with white rounded-corner backgrounds
Replace all logo/favicon assets with the new lobster logo. All variants
use a solid white background with rounded corners (12% radius).

Updated assets: clawd-logo.png (512), clawd-mark.png (256),
logo192.png, logo512.png, favicon.ico (multi-res), logo.svg.
Added explicit favicon/icon/manifest link tags to __root.tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 04:43:05 -05:00
Val AlexanderandClaude Opus 4.6 4d880acccc fix: address PR review — button height, one-shot fetch, query cap
- Sign-in button: add size="sm" (min-h 34px) to align with the 38px
  theme toggle group in the navbar
- UserBadge tooltip: replace reactive useQuery with one-shot
  ConvexHttpClient.query() so hover stats don't leave persistent
  subscriptions on the skills table
- getHoverStats: replace unbounded .collect() with .take(500) to cap
  per-transaction reads for prolific users

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 04:06:23 -05:00
Val AlexanderandClaude Opus 4.6 5e7e4e83bd Design system overhaul: unified tokens, component authority, standardized layout
Establish a single design token system and make React UI components the
canonical styling authority, replacing the dual CSS-class/inline-style
approach. No product behavior changes.

## Phase 1 — Design Foundation

- Add 25+ semantic CSS custom properties to :root (status colors, form
  controls, interactive states, overlay, shadows, layout widths) with
  matching light-theme overrides
- Expand @theme block from 24 → 80+ entries mapping all tokens to
  Tailwind v4 utilities (spacing, typography, status, form, layout)
- Migrate 11 ui/ primitives to use tokens instead of hardcoded rgba/hex:
  input, textarea, label, select, badge, button, card, dialog, sheet,
  dropdown-menu, toggle-group
- Introduce --accent-fg token for guaranteed text contrast on accent
  backgrounds across both themes
- Add --page-max (1536px), --page-narrow (900px), --page-min (320px)
  layout tokens; standardize all page/nav/footer max-widths
- Deprecate CSS classes (.btn, .tag, .card, .form-input, .form-label)
  with @deprecated comments — React components are now canonical

## Phase 2 — IA & Navigation

- Extract shared nav config (src/lib/nav-items.ts) consumed by Header
  and Footer, eliminating 3-place navigation duplication
- Add beforeLoad guard on /$owner/$slug catch-all route to validate
  owner handles and prevent confusing 404s on typos
- Add UserBadge hover tooltip with lazy-fetched stats (published skills,
  stars, downloads) via new getHoverStats Convex query
- Wrap app in TooltipProvider (src/components/AppProviders.tsx)

## Phase 3 — UI Sweep

- Migrate 54 .btn usages → <Button> across 13 route/component files
- Migrate 24 .tag usages → <Badge> across 12 files
- Eliminate ~89 inline style={{}} occurrences → Tailwind utilities
- Replace .card divs → <Card> component in detail/dashboard/search pages
- Fix text-white → text-accent-fg in 5 locations (SkillsToolbar,
  InstallSwitcher, toggle-group, .star-toggle, .btn-primary)
- Standardize all page max-widths to var(--page-max) token (was 6
  different values: 900–1450px)
- Add min-width: var(--page-min) to .app-shell
- Convert raw styled <a>/<button> elements to <Button asChild> pattern

## New files

- src/lib/nav-items.ts — shared navigation config
- src/lib/icon-sizes.ts — ICON_SM/MD/LG constants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 03:57:19 -05:00
82 changed files with 1243 additions and 6129 deletions
-6
View File
@@ -1,11 +1,5 @@
# Changelog
## Unreleased
### Changed
- Search: add CJK tokenization support (Chinese/Japanese/Korean) with Intl.Segmenter plus fallback behavior to improve skill query matching (#1596) (thanks @pq-dong).
## 0.10.0 - 2026-04-05
### Added
-356
View File
@@ -1,356 +0,0 @@
# ClawHub Design System
This document outlines the design rules, patterns, and guidelines for the ClawHub platform to ensure consistency, accessibility, and maintainability across all components.
---
## Color System
### Brand Palette (OpenClaw)
ClawHub uses a strict **3-5 color palette** based on the OpenClaw brand:
| Token | Light Mode | Dark Mode | Usage |
|-------|------------|-----------|-------|
| `--accent` | `#dc2626` | `#dc2626` | Primary actions, interactive elements, emphasis |
| `--accent-deep` | `#b91c1c` | `#ef4444` | Hover states, secondary emphasis |
| `--ink` | `#0a0a0a` | `#fafafa` | Primary text |
| `--ink-soft` | `#525252` | `#a1a1a1` | Secondary text, descriptions |
| `--surface` | `#ffffff` | `#121212` | Card backgrounds, elevated surfaces |
| `--bg` | `#fafafa` | `#0a0a0a` | Page background |
### Rules
1. **Never exceed 5 colors** without explicit design approval
2. **Never use purple/violet prominently** unless explicitly requested
3. **Always override text color** when changing background color to ensure contrast
4. **Use semantic tokens** (`--accent`, `--ink`, `--surface`) instead of raw colors
---
## Typography
### Font Stack
```css
--font-sans: 'Geist', system-ui, sans-serif;
--font-mono: 'Geist Mono', monospace;
--font-display: 'Geist', system-ui, sans-serif;
```
### Scale
| Token | Size | Usage |
|-------|------|-------|
| `--fs-xs` | 0.75rem (12px) | Labels, badges, metadata |
| `--fs-sm` | 0.875rem (14px) | Body text, descriptions |
| `--fs-base` | 1rem (16px) | Default body text |
| `--fs-md` | 1.125rem (18px) | Subheadings |
| `--fs-lg` | 1.25rem (20px) | Section titles |
| `--fs-xl` | 1.5rem (24px) | Page headings |
### Rules
1. **Maximum 2 font families** per page
2. **Line height 1.4-1.6** for body text (use `leading-relaxed`)
3. **Never use decorative fonts** for body text
4. **Minimum font size: 14px** for readability
5. Use `text-balance` or `text-pretty` for titles
---
## Layout
### Method Priority
Use this hierarchy for layout decisions:
1. **Flexbox** - Default for most layouts
2. **CSS Grid** - Only for complex 2D layouts (cards, galleries)
3. **Never use floats** or absolute positioning unless absolutely necessary
### Spacing Scale
```css
--space-1: 0.25rem /* 4px */
--space-2: 0.5rem /* 8px */
--space-3: 0.75rem /* 12px */
--space-4: 1rem /* 16px */
--space-5: 1.5rem /* 24px */
--space-6: 2rem /* 32px */
```
### Grid Patterns
#### Auto-fit Grid (Recommended for Cards)
```css
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
```
- Automatically adjusts columns based on container width
- Prevents orphan items on partial rows
- Maintains consistent card widths
#### Fixed Grid (When exact columns needed)
```css
/* 3-column at desktop, 2 at tablet, 1 at mobile */
grid-template-columns: repeat(3, minmax(0, 1fr));
@media (max-width: 860px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 520px) {
grid-template-columns: 1fr;
}
```
### Container Widths
| Size | Max Width | Usage |
|------|-----------|-------|
| Default | `--page-max` (1200px) | Standard pages |
| Narrow | `--page-narrow` (720px) | Reading content, forms |
| Wide | Full width | Dashboards, data tables |
---
## Components
### Cards
```css
.card {
padding: var(--space-4);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--surface);
}
```
**Rules:**
- Always use `display: flex; flex-direction: column;` for consistent height
- Add `flex: 1` to content area for equal-height cards in grids
- Include hover state with `border-color` and subtle `box-shadow`
### Buttons
| Variant | Usage |
|---------|-------|
| `primary` | Main actions (Submit, Save, Download) |
| `secondary` | Alternative actions |
| `ghost` | Tertiary actions, navigation |
| `destructive` | Delete, remove, dangerous actions |
**Rules:**
- Always include visible focus state
- Minimum touch target: 44x44px on mobile
- Include `aria-label` when icon-only
### Form Controls
- Labels above inputs (not inline)
- Error states use `--status-error-fg`
- Focus rings use `--accent` with 0.2 opacity
- Minimum input height: 40px
---
## Responsive Breakpoints
```css
/* Mobile first - base styles for mobile */
@media (min-width: 520px) {
/* Small tablets, large phones */
}
@media (min-width: 640px) {
/* Tablets */
}
@media (min-width: 860px) {
/* Small desktops, landscape tablets */
}
@media (min-width: 1024px) {
/* Desktops */
}
@media (min-width: 1280px) {
/* Large desktops */
}
```
### Rules
1. **Mobile-first approach** - Base styles target mobile
2. **Progressive enhancement** - Add complexity as viewport increases
3. **Test intermediate breakpoints** - Avoid jarring layout jumps
4. **Never hide critical content** on mobile
---
## Accessibility
### Color Contrast
- Normal text: Minimum 4.5:1 ratio
- Large text (18px+): Minimum 3:1 ratio
- Interactive elements: Minimum 3:1 ratio
### Focus States
```css
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 2px;
}
```
### Screen Readers
- Use `sr-only` class for visually hidden but accessible text
- Always include `alt` text for images (empty `alt=""` for decorative)
- Use semantic HTML elements (`main`, `nav`, `article`, `section`)
- Proper heading hierarchy (h1 > h2 > h3, no skipping)
### Motion
```css
/* Respect user preference */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
---
## Animation
### Timing
```css
--transition-fast: 150ms;
--transition-base: 200ms;
--transition-slow: 300ms;
```
### Easing
- Use `ease` or `ease-out` for most transitions
- Use `ease-in-out` for enter/exit animations
- Never use `linear` except for continuous animations
### Rules
1. **Subtle by default** - Avoid flashy animations
2. **Purpose-driven** - Animation should provide feedback
3. **Respect preferences** - Support `prefers-reduced-motion`
4. **Performance** - Use `transform` and `opacity` only
---
## Icons
### Usage
- Use Lucide icons consistently
- Standard sizes: 14px, 16px, 20px, 24px
- Include `aria-hidden="true"` for decorative icons
- Never use emojis as icons
### Placement
- Left of labels in buttons and navigation
- Right of labels for external links or dropdowns
- Centered when used alone with `aria-label`
---
## Dark Mode
### Implementation
```css
[data-theme="dark"] {
/* Dark mode overrides */
}
```
### Rules
1. Never use pure white (`#ffffff`) on dark backgrounds
2. Reduce shadow intensity in dark mode
3. Adjust image brightness if needed
4. Test contrast ratios in both modes
---
## Performance
### CSS
1. Use CSS custom properties for theming
2. Avoid deeply nested selectors (max 3 levels)
3. Use `will-change` sparingly
4. Prefer `transform` over `top/left` for animations
### Images
1. Always specify `width` and `height` attributes
2. Use `loading="lazy"` for below-fold images
3. Use appropriate formats (WebP with fallbacks)
4. Include placeholder or skeleton states
---
## Code Style
### CSS Class Naming
```css
/* Component */
.component-name { }
/* Component modifier */
.component-name.variant { }
/* Component child */
.component-name-child { }
/* State */
.component-name.is-active { }
.component-name[data-state="open"] { }
```
### File Organization
```
src/
components/
ui/ # Primitive components (Button, Input, Card)
layout/ # Layout components (Container, Header)
styles.css # Global styles and design tokens
lib/
theme.ts # Theme utilities
preferences.ts # User preference management
```
---
## Checklist
Before shipping any UI changes, verify:
- [ ] Color contrast meets WCAG AA standards
- [ ] Focus states are visible
- [ ] Layout works at all breakpoints
- [ ] Animations respect `prefers-reduced-motion`
- [ ] Text is readable at default browser zoom
- [ ] Interactive elements have 44px minimum touch target
- [ ] Semantic HTML is used appropriately
- [ ] Dark mode has been tested
+23 -236
View File
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "clawhub",
@@ -20,8 +19,6 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -34,7 +31,6 @@
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "1.167.2",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clawhub-schema": "workspace:*",
"clsx": "^2.1.1",
"convex": "^1.34.1",
@@ -44,8 +40,6 @@
"ignore": "^7.0.5",
"lucide-react": "^0.577.0",
"monaco-editor": "^0.55.1",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"nitro": "3.0.260311-beta",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -56,7 +50,6 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"vite-tsconfig-paths": "^6.1.1",
"yaml": "^2.8.3",
"zod": "^4.3.6",
@@ -79,7 +72,7 @@
"oxlint-tsgolint": "^0.17.4",
"typescript": "^5.9.3",
"undici": "^7.24.7",
"vite": "8.0.5",
"vite": "8.0.1",
"vitest": "^4.1.2",
},
},
@@ -272,56 +265,6 @@
"@fontsource/manrope": ["@fontsource/manrope@5.2.8", "", {}, "sha512-gJHJmcuUk7qWcNCfcAri/DJQtXtBYqi9yKratr4jXhSo0I3xUtNNKI+igQIcw5c+m95g0vounk8ZnX/kb8o0TA=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -338,24 +281,6 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="],
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
"@oozcitak/infra": ["@oozcitak/infra@2.0.2", "", { "dependencies": { "@oozcitak/util": "^10.0.0" } }, "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA=="],
@@ -372,7 +297,7 @@
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
"@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.41.0", "", { "os": "android", "cpu": "arm" }, "sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw=="],
@@ -514,9 +439,7 @@
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
@@ -550,35 +473,35 @@
"@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.6.2", "", {}, "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm" }, "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.12", "", { "os": "none", "cpu": "arm64" }, "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.12", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "x64" }, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
@@ -612,8 +535,6 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
@@ -822,8 +743,6 @@
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clawhub": ["clawhub@workspace:packages/clawhub"],
"clawhub-schema": ["clawhub-schema@workspace:packages/schema"],
@@ -832,8 +751,6 @@
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
@@ -1178,10 +1095,6 @@
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"nf3": ["nf3@0.3.13", "", {}, "sha512-drDt0yl4d/yUhlpD0GzzqahSpA5eUNeIfFq0/aoZb0UlPY0ZwP4u1EfREVvZrYdEnJ3OU9Le9TrzbvWgEkkeKw=="],
"nitro": ["nitro@3.0.260311-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.4", "db0": "^0.3.4", "env-runner": "^0.1.6", "h3": "^2.0.1-rc.16", "hookable": "^6.0.1", "nf3": "^0.3.11", "ocache": "^0.1.2", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.0-rc.8", "srvx": "^0.11.9", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.6" }, "peerDependencies": { "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.59.0", "vite": "^7 || ^8 || >=8.0.0-0", "xml2js": "^0.6.2", "zephyr-agent": "^0.1.15" }, "optionalPeers": ["dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-0o0fJ9LUh4WKUqJNX012jyieUOtMCnadkNDWr0mHzdraoHpJP/1CGNefjRyZyMXSpoJfwoWdNEZu2iGf35TUvQ=="],
@@ -1292,7 +1205,7 @@
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="],
"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=="],
"rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
@@ -1310,8 +1223,6 @@
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
@@ -1352,8 +1263,6 @@
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
@@ -1398,8 +1307,6 @@
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
@@ -1438,7 +1345,7 @@
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@8.0.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "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 || ^0.28.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-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ=="],
"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=="],
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
@@ -1492,14 +1399,10 @@
"@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-dropdown-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
@@ -1512,14 +1415,14 @@
"@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
@@ -1532,12 +1435,6 @@
"@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-switch/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
@@ -1552,8 +1449,6 @@
"@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
@@ -1596,12 +1491,6 @@
"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=="],
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
@@ -1614,110 +1503,8 @@
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
"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=="],
"nitro/rolldown/@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
"nitro/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
"nitro/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
"nitro/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
"nitro/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
"nitro/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
"nitro/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="],
"nitro/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
"nitro/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
"nitro/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
"nitro/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
"nitro/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
"nitro/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
"nitro/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="],
"nitro/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="],
"nitro/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=="],
"nitro/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
"vitest/vite/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=="],
"vitest/vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
"vitest/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
"vitest/vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
"vitest/vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
"vitest/vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
"vitest/vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
"vitest/vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="],
"vitest/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
"vitest/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
"vitest/vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
"vitest/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
"vitest/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
"vitest/vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
"vitest/vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="],
"vitest/vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="],
"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
@@ -77,7 +77,6 @@ import type * as lib_reservedSlugs from "../lib/reservedSlugs.js";
import type * as lib_searchText from "../lib/searchText.js";
import type * as lib_securityPrompt from "../lib/securityPrompt.js";
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
import type * as lib_skillCapabilityTags from "../lib/skillCapabilityTags.js";
import type * as lib_skillPublish from "../lib/skillPublish.js";
import type * as lib_skillQuality from "../lib/skillQuality.js";
import type * as lib_skillSafety from "../lib/skillSafety.js";
@@ -194,7 +193,6 @@ declare const fullApi: ApiFromModules<{
"lib/searchText": typeof lib_searchText;
"lib/securityPrompt": typeof lib_securityPrompt;
"lib/skillBackfill": typeof lib_skillBackfill;
"lib/skillCapabilityTags": typeof lib_skillCapabilityTags;
"lib/skillPublish": typeof lib_skillPublish;
"lib/skillQuality": typeof lib_skillQuality;
"lib/skillSafety": typeof lib_skillSafety;
-73
View File
@@ -237,74 +237,6 @@ xuezh snapshot --profile default
xuezh review next --limit 10
xuezh audio process-voice --file ./utterance.wav
\`\`\`
`,
},
{
slug: "hanzi-helper",
displayName: "汉字助手",
summary: "汉字学习与分析工具,支持笔画查询、部首检索和组词生成。",
version: "0.1.0",
metadata: {
clawdbot: {
nix: {
plugin: "github:example/hanzi-helper",
systems: ["aarch64-darwin", "x86_64-linux"],
},
config: {
requiredEnv: ["HANZI_DB_PATH"],
stateDirs: [".config/hanzi"],
example:
'config = { env = { HANZI_DB_PATH = ".config/hanzi/db"; }; stateDirs = [ ".config/hanzi" ]; };',
},
cliHelp: `汉字助手 - Chinese character learning and analysis
Usage:
hanzi-helper [command]
Available Commands:
lookup 查询汉字信息(笔画、部首、释义)
radical 按部首检索汉字
stroke 按笔画数筛选汉字
words 生成汉字组词
practice 练习汉字书写
quiz 汉字听写测试
Flags:
-h, --help help for hanzi-helper
--json Output JSON
`,
},
},
rawSkillMd: `---
name: hanzi-helper
description: 汉字学习与分析工具,提供笔画查询、部首检索、组词生成和汉字听写练习功能。
---
# 汉字助手
## 功能介绍
汉字助手是一个强大的中文汉字学习工具,帮助用户深入了解每个汉字的结构和含义。
## CLI
\`\`\`bash
hanzi-helper lookup --char 学
hanzi-helper radical --name 木
hanzi-helper stroke --count 8
hanzi-helper words --char 大 --limit 20
\`\`\`
## 使用场景
- **汉字查询**:输入任意汉字,查看笔画数、部首、繁体形式和基本释义
- **部首检索**:按部首浏览相关汉字,了解汉字的分类规律
- **组词生成**:输入一个汉字,自动生成常用词语和成语
- **听写练习**:随机生成汉字听写测试,巩固学习效果
## 学习建议
建议每天学习五个新汉字,结合组词和例句加深记忆。坚持使用听写练习功能可以有效提高汉字识别能力。
`,
},
];
@@ -469,11 +401,6 @@ export const seedSkillMutation = internalMutation({
createdAt: now,
updatedAt: now,
});
await ctx.db.patch(userId, {
publishedSkills: 1,
totalStars: 0,
totalDownloads: 0,
});
const versionId = await ctx.db.insert("skillVersions", {
skillId,
-16
View File
@@ -448,22 +448,6 @@ const EXTRA_SEED_SKILLS: SeedSkillSpec[] = [
["SSH_KEY_DIR"],
["generate", "rotate", "deploy", "list", "revoke"],
),
// CJK Language Support (2)
makeSkill(
"nihongo-check",
"日本語チェッカー",
"日本語文章の文法チェックと翻訳支援ツール。Japanese grammar checker and translation assistant.",
["NIHONGO_API_KEY"],
["check", "translate", "kanji", "grammar", "vocabulary"],
),
makeSkill(
"hangukgeo-helper",
"한국어 도우미",
"한국어 학습 보조 도구입니다. Korean language learning assistant with vocabulary and grammar support.",
["HANGUL_API_KEY"],
["learn", "quiz", "vocabulary", "grammar", "pronunciation"],
),
];
function injectMetadata(rawSkillMd: string, metadata: Record<string, unknown>) {
-45
View File
@@ -2552,51 +2552,6 @@ 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,7 +1433,6 @@ 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;
};
+4 -6
View File
@@ -1,4 +1,4 @@
import { CliPublishRequestSchema, normalizeTextContentType, parseArk } from "clawhub-schema";
import { CliPublishRequestSchema, parseArk } from "clawhub-schema";
import { internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
@@ -25,9 +25,7 @@ export function safeTextFileResponse(params: {
size: number;
headers?: HeadersInit;
}) {
const contentType =
normalizeTextContentType(params.path, params.contentType) ?? params.contentType;
const isSvg = isSvgLike(contentType, params.path);
const isSvg = isSvgLike(params.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
@@ -35,8 +33,8 @@ export function safeTextFileResponse(params: {
const headers = mergeHeaders(
params.headers,
{
"Content-Type": contentType
? `${contentType}; charset=utf-8`
"Content-Type": params.contentType
? `${params.contentType}; charset=utf-8`
: "text/plain; charset=utf-8",
"Cache-Control": "private, max-age=60",
ETag: params.sha256,
+3 -5
View File
@@ -1,11 +1,9 @@
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";
import { applyRateLimit, parseBearerToken } from "../lib/httpRateLimit";
import { parseBooleanQueryParam, resolveBooleanQueryParam } from "../lib/httpUtils";
import type { LlmEvalDimension } from "../lib/securityPrompt";
import { publishVersionForUser } from "../skills";
import {
MAX_RAW_FILE_BYTES,
@@ -208,7 +206,7 @@ type SkillSecuritySnapshot = {
normalizedStatus: NormalizedSecurityStatus;
confidence: string | null;
summary: string | null;
dimensions: LlmEvalDimension[] | null;
dimensions: NonNullable<Doc<"skillVersions">["llmAnalysis"]>["dimensions"] | null;
guidance: string | null;
findings: string | null;
model: string | null;
@@ -264,7 +262,7 @@ function mergeSecurityStatuses(statuses: NormalizedSecurityStatus[]) {
}
function hasLlmDimensionWarnings(
dimensions: LlmEvalDimension[] | undefined,
dimensions: NonNullable<Doc<"skillVersions">["llmAnalysis"]>["dimensions"] | undefined,
) {
if (!Array.isArray(dimensions)) return false;
return dimensions.some((dimension) => {
@@ -735,7 +733,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
path: file.path,
size: file.size,
sha256: file.sha256,
contentType: normalizeTextContentType(file.path, file.contentType) ?? null,
contentType: file.contentType ?? null,
})),
security: security ?? undefined,
},
+1 -4
View File
@@ -22,10 +22,7 @@ export async function starsPostRouterV1Handler(ctx: ActionCtx, request: Request)
skillId: skill._id,
});
return json(result, 200, rate.headers);
} catch (e) {
if (e instanceof Error && e.message === "Skill not found") {
return text("Skill not found", 404, rate.headers);
}
} catch {
return text("Unauthorized", 401, rate.headers);
}
}
-167
View File
@@ -92,173 +92,6 @@ describe("moderationEngine", () => {
expect(result.status).toBe("suspicious");
});
it("flags raw user placeholders embedded in generated Python source within markdown", () => {
const result = runStaticModerationScan({
slug: "word-document-organizer",
displayName: "Word Document Organizer",
summary: "Organize and restyle Word documents",
frontmatter: {},
metadata: {},
files: [{ path: "SKILL.md", size: 512 }],
fileContents: [
{
path: "SKILL.md",
content: [
"Generate a Python helper like this:",
"```python",
'doc_path = "${document_path}"',
'output_path = "${output_path}" if "${output_path}" else doc_path',
'template = "${style_template}"',
"```",
].join("\n"),
},
],
});
expect(result.reasonCodes).toContain("suspicious.generated_source_template_injection");
expect(result.status).toBe("suspicious");
});
it("does not flag ordinary placeholder usage outside generated source assignments", () => {
const result = runStaticModerationScan({
slug: "api-docs",
displayName: "API Docs",
summary: "Shows users how to call an API",
frontmatter: {},
metadata: {},
files: [{ path: "SKILL.md", size: 256 }],
fileContents: [
{
path: "SKILL.md",
content: [
"Use this request template:",
"```bash",
'curl "https://example.com/search?q=${query}"',
"```",
].join("\n"),
},
],
});
expect(result.reasonCodes).not.toContain("suspicious.generated_source_template_injection");
expect(result.status).toBe("clean");
});
it("flags hardcoded connection_id UUIDs in markdown examples", () => {
const result = runStaticModerationScan({
slug: "api-gateway",
displayName: "API Gateway",
summary: "Route API calls through an authenticated gateway",
frontmatter: {},
metadata: {},
files: [{ path: "SKILL.md", size: 256 }],
fileContents: [
{
path: "SKILL.md",
content: [
"Use this payload:",
"```json",
'{"connection_id": "21fd90f9-5935-43cd-b6c8-bde9d915ca80"}',
"```",
].join("\n"),
},
],
});
expect(result.reasonCodes).toContain("suspicious.exposed_resource_identifier");
expect(result.status).toBe("suspicious");
expect(
result.findings.find((finding) => finding.message.includes("connection_id"))?.message,
).toContain("connection_id");
});
it("flags hardcoded Google Sheets spreadsheet IDs in markdown examples", () => {
const result = runStaticModerationScan({
slug: "api-gateway",
displayName: "API Gateway",
summary: "Route API calls through an authenticated gateway",
frontmatter: {},
metadata: {},
files: [{ path: "SKILL.md", size: 256 }],
fileContents: [
{
path: "SKILL.md",
content: [
"Call the Sheets bridge like this:",
"```python",
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/122BS1sFN2RKL8AOUQjkLdubzOwgqzPT64KfZ2rvYI4M/values/Sheet1!A1:B2')",
"```",
].join("\n"),
},
],
});
expect(result.reasonCodes).toContain("suspicious.exposed_resource_identifier");
expect(result.status).toBe("suspicious");
expect(
result.findings.find((finding) => finding.message.includes("spreadsheet ID"))?.message,
).toContain("spreadsheet ID");
});
it("does not flag placeholder resource identifiers in markdown examples", () => {
const result = runStaticModerationScan({
slug: "api-gateway",
displayName: "API Gateway",
summary: "Route API calls through an authenticated gateway",
frontmatter: {},
metadata: {},
files: [{ path: "SKILL.md", size: 256 }],
fileContents: [
{
path: "SKILL.md",
content: [
"Use placeholders in public docs:",
"```json",
'{"connection_id": "YOUR_CONNECTION_ID"}',
"```",
"```python",
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/YOUR_SPREADSHEET_ID/values/Sheet1!A1:B2')",
"```",
].join("\n"),
},
],
});
expect(result.reasonCodes).not.toContain("suspicious.exposed_resource_identifier");
expect(result.status).toBe("clean");
});
it("flags a real spreadsheet ID even when a placeholder URL appears first", () => {
const result = runStaticModerationScan({
slug: "api-gateway",
displayName: "API Gateway",
summary: "Route API calls through an authenticated gateway",
frontmatter: {},
metadata: {},
files: [{ path: "SKILL.md", size: 512 }],
fileContents: [
{
path: "SKILL.md",
content: [
"Placeholder example first:",
"```python",
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/YOUR_SPREADSHEET_ID/values/Sheet1!A1:B2')",
"```",
"Real leaked URL later:",
"```python",
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/122BS1sFN2RKL8AOUQjkLdubzOwgqzPT64KfZ2rvYI4M/values/Sheet1!A1:B2')",
"```",
].join("\n"),
},
],
});
expect(result.reasonCodes).toContain("suspicious.exposed_resource_identifier");
expect(
result.findings.find((finding) => finding.message.includes("spreadsheet ID"))?.line,
).toBe(7);
});
it("blocks obfuscated terminal install payload prompts in markdown", () => {
const result = runStaticModerationScan({
slug: "evil-installer",
-67
View File
@@ -50,14 +50,6 @@ const CODE_EXTENSION = /\.(js|ts|mjs|cjs|mts|cts|jsx|tsx|py|sh|bash|zsh|rb|go)$/
const STANDARD_PORTS = new Set([80, 443, 8080, 8443, 3000]);
const RAW_IP_URL_PATTERN = /https?:\/\/\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?(?:\/|["'])/i;
const INSTALL_PACKAGE_PATTERN = /installer-package\s*:\s*https?:\/\/[^\s"'`]+/i;
const GENERATED_SOURCE_PLACEHOLDER_PATTERN =
/^\s*[A-Za-z_][A-Za-z0-9_]*\s*=.*["']\$\{[A-Za-z_][A-Za-z0-9_-]*\}["']/m;
const GENERATED_SOURCE_CONTEXT_PATTERN =
/```(?:python|py|javascript|js|typescript|ts|shell|bash|sh)\b|cat\s*(?:>|>>)?\s*[^`\n]*\.(?:py|js|ts|sh)\b|python3?\b|node\b/i;
const HARDCODED_CONNECTION_ID_PATTERN =
/["']connection_id["']\s*:\s*["'][0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}["']/i;
const GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN =
/https?:\/\/[^\s"'`]*\/spreadsheets\/([A-Za-z0-9_-]{20,})\/[^\s"'`]*/i;
function hasMaliciousInstallPrompt(content: string) {
const hasTerminalInstruction =
@@ -83,10 +75,6 @@ function truncateEvidence(evidence: string, maxLen = 160) {
return `${evidence.slice(0, maxLen)}...`;
}
function looksLikePlaceholderIdentifier(identifier: string) {
return /^[A-Z0-9_]+$/.test(identifier) || /(your|example|placeholder)/i.test(identifier);
}
function addFinding(
findings: ModerationFinding[],
finding: Omit<ModerationFinding, "evidence"> & { evidence: string },
@@ -104,14 +92,6 @@ function findFirstLine(content: string, pattern: RegExp) {
return { line: 1, text: lines[0] ?? "" };
}
function findLineAtIndex(content: string, index: number) {
const line = content.slice(0, index).split("\n").length;
const lineStart = content.lastIndexOf("\n", Math.max(0, index - 1)) + 1;
const nextNewline = content.indexOf("\n", index);
const lineEnd = nextNewline === -1 ? content.length : nextNewline;
return { line, text: content.slice(lineStart, lineEnd) };
}
function scanCodeFile(path: string, content: string, findings: ModerationFinding[]) {
if (!CODE_EXTENSION.test(path)) return;
@@ -247,53 +227,6 @@ function scanMarkdownFile(path: string, content: string, findings: ModerationFin
evidence: match.text,
});
}
if (
GENERATED_SOURCE_PLACEHOLDER_PATTERN.test(content) &&
GENERATED_SOURCE_CONTEXT_PATTERN.test(content)
) {
const match = findFirstLine(content, GENERATED_SOURCE_PLACEHOLDER_PATTERN);
addFinding(findings, {
code: REASON_CODES.GENERATED_SOURCE_TEMPLATE,
severity: "critical",
file: path,
line: match.line,
message: "User-controlled placeholder is embedded directly into generated source code.",
evidence: match.text,
});
}
if (HARDCODED_CONNECTION_ID_PATTERN.test(content)) {
const match = findFirstLine(content, HARDCODED_CONNECTION_ID_PATTERN);
addFinding(findings, {
code: REASON_CODES.EXPOSED_RESOURCE_IDENTIFIER,
severity: "critical",
file: path,
line: match.line,
message: "Example code exposes a concrete connection_id instead of a placeholder.",
evidence: match.text,
});
}
const spreadsheetUrlPattern = new RegExp(
GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN.source,
`${GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN.flags.replaceAll("g", "")}g`,
);
for (const spreadsheetUrlMatch of content.matchAll(spreadsheetUrlPattern)) {
const spreadsheetId = spreadsheetUrlMatch[1];
if (!spreadsheetId || looksLikePlaceholderIdentifier(spreadsheetId)) continue;
const match = findLineAtIndex(content, spreadsheetUrlMatch.index ?? 0);
addFinding(findings, {
code: REASON_CODES.EXPOSED_RESOURCE_IDENTIFIER,
severity: "critical",
file: path,
line: match.line,
message: "Example code exposes a concrete Google Sheets spreadsheet ID instead of a placeholder.",
evidence: match.text,
});
break;
}
}
function scanManifestFile(path: string, content: string, findings: ModerationFinding[]) {
+1 -3
View File
@@ -12,13 +12,11 @@ export type ModerationFinding = {
evidence: string;
};
export const MODERATION_ENGINE_VERSION = "v2.4.0";
export const MODERATION_ENGINE_VERSION = "v2.2.0";
export const REASON_CODES = {
DANGEROUS_EXEC: "suspicious.dangerous_exec",
DYNAMIC_CODE: "suspicious.dynamic_code_execution",
GENERATED_SOURCE_TEMPLATE: "suspicious.generated_source_template_injection",
EXPOSED_RESOURCE_IDENTIFIER: "suspicious.exposed_resource_identifier",
CREDENTIAL_HARVEST: "suspicious.env_credential_access",
EXFILTRATION: "suspicious.potential_exfiltration",
OBFUSCATED_CODE: "suspicious.obfuscated_code",
+1 -3
View File
@@ -5,8 +5,6 @@ function pick<T extends Record<string, unknown>, K extends keyof T>(obj: T, keys
return Object.fromEntries(keys.map((key) => [key, obj[key]])) as Pick<T, K>;
}
type SharedPackageKey = Extract<keyof Doc<"packages">, keyof Doc<"packageSearchDigest">>;
const SHARED_KEYS = [
"name",
"normalizedName",
@@ -24,7 +22,7 @@ const SHARED_KEYS = [
"softDeletedAt",
"createdAt",
"updatedAt",
] as const satisfies readonly SharedPackageKey[];
] as const satisfies readonly (keyof Doc<"packages"> & keyof Doc<"packageSearchDigest">)[];
const CAPABILITY_SHARED_KEYS = [
"packageId",
-51
View File
@@ -47,55 +47,4 @@ describe("searchText", () => {
it("normalize uses lowercase", () => {
expect(__test.normalize("AbC")).toBe("abc");
});
// CJK (Chinese, Japanese, Korean) support tests
describe("CJK tokenization", () => {
it("tokenizes Chinese text using Intl.Segmenter", () => {
const tokens = tokenize("中文搜索");
expect(tokens.length).toBeGreaterThan(0);
expect(tokens).toContain("中文");
expect(tokens).toContain("搜索");
});
it("tokenizes mixed Chinese and English text", () => {
const tokens = tokenize("React 组件开发");
expect(tokens).toContain("react");
expect(tokens.some((t) => t.includes("组") || t.includes("件"))).toBe(true);
});
it("matches Chinese query tokens against Chinese skill names", () => {
const queryTokens = tokenize("翻译");
const skillName = "AI翻译助手";
expect(matchesExactTokens(queryTokens, [skillName])).toBe(true);
});
it("matches partial Chinese words", () => {
const queryTokens = tokenize("助手");
const skillName = "AI翻译助手";
expect(matchesExactTokens(queryTokens, [skillName])).toBe(true);
});
it("handles Japanese text", () => {
const tokens = tokenize("こんにちは世界");
expect(tokens.length).toBeGreaterThan(0);
});
it("handles Korean text", () => {
const tokens = tokenize("안녕하세요");
expect(tokens.length).toBeGreaterThan(0);
});
it("returns empty array for empty or whitespace-only input", () => {
expect(tokenize("")).toEqual([]);
expect(tokenize(" ")).toEqual([]);
expect(tokenize("!!!")).toEqual([]);
});
it("detects CJK language correctly", () => {
expect(__test.detectCJKLanguage("中文")).toBe("zh");
expect(__test.detectCJKLanguage("こんにちは")).toBe("ja");
expect(__test.detectCJKLanguage("안녕하세요")).toBe("ko");
expect(__test.detectCJKLanguage("hello")).toBeNull();
});
});
});
+3 -130
View File
@@ -1,135 +1,12 @@
const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf\u3041-\u3096\u30a1-\u30fa\uac00-\ud7af]/;
const hasSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl;
let zhSegmenter: Intl.Segmenter | null = null;
let jaSegmenter: Intl.Segmenter | null = null;
let koSegmenter: Intl.Segmenter | null = null;
function getZhSegmenter(): Intl.Segmenter {
if (!zhSegmenter) {
zhSegmenter = new Intl.Segmenter("zh-CN", { granularity: "word" });
}
return zhSegmenter;
}
function getJaSegmenter(): Intl.Segmenter {
if (!jaSegmenter) {
jaSegmenter = new Intl.Segmenter("ja", { granularity: "word" });
}
return jaSegmenter;
}
function getKoSegmenter(): Intl.Segmenter {
if (!koSegmenter) {
koSegmenter = new Intl.Segmenter("ko", { granularity: "word" });
}
return koSegmenter;
}
/**
* Fallback: split CJK text into individual characters.
* Used when Intl.Segmenter is unavailable (e.g. stripped V8 runtime).
*/
function segmentCJKByChar(text: string): string[] {
const tokens: string[] = [];
for (const ch of text) {
if (CJK_RE.test(ch)) {
tokens.push(ch);
}
}
return tokens;
}
const WORD_RE = /[a-z0-9]+/g;
function normalize(value: string) {
return value.toLowerCase();
}
/**
* Detect the primary CJK language in a text
* Returns 'zh' for Chinese, 'ja' for Japanese, 'ko' for Korean, or null
*/
function detectCJKLanguage(text: string): "zh" | "ja" | "ko" | null {
const chineseCount = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
const hiraganaCount = (text.match(/[\u3040-\u309f]/g) || []).length;
const katakanaCount = (text.match(/[\u30a0-\u30ff]/g) || []).length;
const hangulCount = (text.match(/[\uac00-\ud7af]/g) || []).length;
if (hiraganaCount + katakanaCount > 0) {
return "ja";
}
if (hangulCount > 0) {
return "ko";
}
if (chineseCount > 0) {
return "zh";
}
return null;
}
/**
* Segment CJK text using Intl.Segmenter, falling back to character-level
* tokenization when the API is unavailable.
*/
function segmentCJK(text: string): string[] {
if (!hasSegmenter) return segmentCJKByChar(text);
const lang = detectCJKLanguage(text);
if (!lang) return [];
let segmenter: Intl.Segmenter;
switch (lang) {
case "ja":
segmenter = getJaSegmenter();
break;
case "ko":
segmenter = getKoSegmenter();
break;
default:
segmenter = getZhSegmenter();
}
const segments: string[] = [];
for (const { segment, isWordLike } of segmenter.segment(text)) {
const trimmed = segment.trim();
if (trimmed && isWordLike) {
segments.push(trimmed);
}
}
return segments;
}
/**
* Tokenize text for search, supporting both English and CJK languages
*
* For English: uses word boundaries (whitespace, punctuation)
* For CJK: uses Intl.Segmenter for proper word segmentation
*/
export function tokenize(value: string): string[] {
if (!value) return [];
const normalized = normalize(value);
if (!CJK_RE.test(normalized)) {
return normalized.match(/[a-z0-9]+/g) ?? [];
}
const tokens: string[] = [];
const parts = normalized.split(/([^\u4e00-\u9fff\u3400-\u4dbf\u3041-\u3096\u30a1-\u30fa\uac00-\ud7af]+)/g);
for (const part of parts) {
if (!part.trim()) continue;
if (CJK_RE.test(part)) {
const cjkTokens = segmentCJK(part);
tokens.push(...cjkTokens);
} else {
const asciiTokens = part.match(/[a-z0-9]+/g) ?? [];
tokens.push(...asciiTokens);
}
}
return tokens;
return normalize(value).match(WORD_RE) ?? [];
}
export function matchesExactTokens(
@@ -147,8 +24,4 @@ export function matchesExactTokens(
);
}
export const __test = {
normalize,
detectCJKLanguage,
segmentCJKByChar,
};
export const __test = { normalize, tokenize, matchesExactTokens };
-2
View File
@@ -1,5 +1,4 @@
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";
@@ -114,7 +113,6 @@ 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");
+1 -3
View File
@@ -6,8 +6,6 @@ function pick<T extends Record<string, unknown>, K extends keyof T>(obj: T, keys
return Object.fromEntries(keys.map((k) => [k, obj[k]])) as Pick<T, K>;
}
type SharedSkillKey = Extract<keyof Doc<"skills">, keyof Doc<"skillSearchDigest">>;
/**
* Fields shared 1:1 between `skills` and `skillSearchDigest` (same name,
* same type). Used by both `extractDigestFields` and `digestToHydratableSkill`
@@ -37,7 +35,7 @@ const SHARED_KEYS = [
"moderationReason",
"createdAt",
"updatedAt",
] as const satisfies readonly SharedSkillKey[];
] as const satisfies readonly (keyof Doc<"skills"> & keyof Doc<"skillSearchDigest">)[];
/** Fields stored in the skillSearchDigest table. */
export type SkillSearchDigestFields = Pick<Doc<"skills">, (typeof SHARED_KEYS)[number]> & {
+1 -6
View File
@@ -1,5 +1,4 @@
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";
@@ -102,11 +101,7 @@ 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,
contentType: normalizeTextContentType(file.path, file.contentType),
};
return { ...file, path };
});
const publishFiles = sanitizedFiles.filter((file) => !isMacJunkPath(file.path));
if (publishFiles.some((file) => !isTextFile(file.path, file.contentType ?? undefined))) {
-67
View File
@@ -1,67 +0,0 @@
import type { Doc, Id } from "../_generated/dataModel";
import type { MutationCtx } from "../_generated/server";
function getSkillContribution(skill: Doc<"skills">) {
if (skill.softDeletedAt) {
return { publishedSkills: 0, totalStars: 0, totalDownloads: 0 };
}
return {
publishedSkills: 1,
totalStars: skill.stats?.stars ?? 0,
totalDownloads: skill.stats?.downloads ?? 0,
};
}
async function patchUserStats(
ctx: Pick<MutationCtx, "db">,
userId: Id<"users">,
delta: { publishedSkills: number; totalStars: number; totalDownloads: number },
) {
const user = await ctx.db.get(userId);
if (!user) return;
await ctx.db.patch(userId, {
publishedSkills: Math.max(0, (user.publishedSkills ?? 0) + delta.publishedSkills),
totalStars: Math.max(0, (user.totalStars ?? 0) + delta.totalStars),
totalDownloads: Math.max(0, (user.totalDownloads ?? 0) + delta.totalDownloads),
});
}
export async function adjustUserSkillStatsForSkillChange(
ctx: Pick<MutationCtx, "db">,
previousSkill: Doc<"skills"> | null | undefined,
nextSkill: Doc<"skills"> | null | undefined,
) {
if (!previousSkill && !nextSkill) return;
const prevOwnerId = previousSkill?.ownerUserId ?? null;
const nextOwnerId = nextSkill?.ownerUserId ?? null;
const prevContribution = previousSkill ? getSkillContribution(previousSkill) : null;
const nextContribution = nextSkill ? getSkillContribution(nextSkill) : null;
if (prevOwnerId && prevOwnerId === nextOwnerId) {
await patchUserStats(ctx, prevOwnerId, {
publishedSkills: (nextContribution?.publishedSkills ?? 0) - (prevContribution?.publishedSkills ?? 0),
totalStars: (nextContribution?.totalStars ?? 0) - (prevContribution?.totalStars ?? 0),
totalDownloads: (nextContribution?.totalDownloads ?? 0) - (prevContribution?.totalDownloads ?? 0),
});
return;
}
if (prevOwnerId) {
await patchUserStats(ctx, prevOwnerId, {
publishedSkills: -(prevContribution?.publishedSkills ?? 0),
totalStars: -(prevContribution?.totalStars ?? 0),
totalDownloads: -(prevContribution?.totalDownloads ?? 0),
});
}
if (nextOwnerId) {
await patchUserStats(ctx, nextOwnerId, {
publishedSkills: nextContribution?.publishedSkills ?? 0,
totalStars: nextContribution?.totalStars ?? 0,
totalDownloads: nextContribution?.totalDownloads ?? 0,
});
}
}
+11 -25
View File
@@ -4,7 +4,6 @@ import {
type PackageChannel,
type PackageFamily,
type PackagePublishRequest,
type PackageVerificationTier,
} from "clawhub-schema";
import { paginationOptsValidator } from "convex/server";
import { ConvexError, v } from "convex/values";
@@ -128,7 +127,7 @@ type PublicPackageListItem = {
latestVersion: string | null;
capabilityTags: string[];
executesCode: boolean;
verificationTier: PackageVerificationTier | null;
verificationTier: Doc<"packageSearchDigest">["verificationTier"] | null;
};
type PackageDigestLike = Pick<
Doc<"packageSearchDigest">,
@@ -164,14 +163,6 @@ type PublicPageCursorState = {
};
const PUBLIC_PAGE_CURSOR_PREFIX = "pkgpage:";
function stringifyId(value: Id<"users"> | Id<"publishers">): string {
return value;
}
function stringifyOptionalId(value: Id<"publishers"> | null | undefined): string | null {
return value ? stringifyId(value) : null;
}
async function runQueryRef<T>(
ctx: { runQuery: (ref: never, args: never) => Promise<unknown> },
ref: unknown,
@@ -221,7 +212,6 @@ type PublicPackageDoc = {
capabilities?: Doc<"packages">["capabilities"];
verification?: Doc<"packages">["verification"];
scanStatus?: Doc<"packages">["scanStatus"];
stats: Doc<"packages">["stats"];
createdAt: number;
updatedAt: number;
};
@@ -327,7 +317,6 @@ function toPublicPackage(
capabilities: pkg.capabilities,
verification: pkg.verification,
scanStatus: pkg.scanStatus,
stats: pkg.stats,
createdAt: pkg.createdAt,
updatedAt: pkg.updatedAt,
};
@@ -2144,11 +2133,6 @@ export const insertReleaseInternal = internalMutation({
args.channel ??
(existing?.channel === "private" ? "private" : publisherTrusted ? "official" : "community");
const nextIsOfficial = nextChannel === "official";
const nextOwnerPublisherId = stringifyOptionalId(args.ownerPublisherId ?? null);
const nextOwnerUserId = stringifyId(args.ownerUserId);
const nextName = args.name;
const nextRuntimeId = args.runtimeId ?? null;
const nextVersion = args.version;
if (existing) {
const existingIsLegacyPersonalPackage =
!existing.ownerPublisherId &&
@@ -2160,18 +2144,18 @@ export const insertReleaseInternal = internalMutation({
const existingOwnerKey = existing.ownerPublisherId
? `publisher:${existing.ownerPublisherId}`
: existingIsLegacyPersonalPackage
? `publisher:${nextOwnerPublisherId}`
? `publisher:${args.ownerPublisherId}`
: `user:${existing.ownerUserId}`;
const nextOwnerKey = nextOwnerPublisherId
? `publisher:${nextOwnerPublisherId}`
: `user:${nextOwnerUserId}`;
const nextOwnerKey = args.ownerPublisherId
? `publisher:${args.ownerPublisherId}`
: `user:${args.ownerUserId}`;
if (existingOwnerKey !== nextOwnerKey) {
throw new ConvexError("Package already exists and belongs to another publisher");
}
}
if (existing && existing.family !== args.family) {
throw new ConvexError(
`Package "${nextName}" already exists as a ${existing.family}; family changes are not allowed`,
`Package "${args.name}" already exists as a ${existing.family}; family changes are not allowed`,
);
}
if (
@@ -2182,7 +2166,7 @@ export const insertReleaseInternal = internalMutation({
existing.runtimeId !== args.runtimeId
) {
throw new ConvexError(
`Package "${nextName}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
`Package "${args.name}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
);
}
if (args.family === "code-plugin" && args.runtimeId) {
@@ -2191,7 +2175,9 @@ export const insertReleaseInternal = internalMutation({
.withIndex("by_runtime_id", (q) => q.eq("runtimeId", args.runtimeId))
.unique();
if (runtimeCollision && runtimeCollision._id !== existing?._id) {
throw new ConvexError(`Plugin id "${nextRuntimeId}" is already claimed by another package`);
throw new ConvexError(
`Plugin id "${args.runtimeId}" is already claimed by another package`,
);
}
}
@@ -2228,7 +2214,7 @@ export const insertReleaseInternal = internalMutation({
q.eq("packageId", existing._id).eq("version", args.version),
)
.unique();
if (releaseExists) throw new ConvexError(`Version ${nextVersion} already exists`);
if (releaseExists) throw new ConvexError(`Version ${args.version} already exists`);
}
const priorReleases = existing
? await ctx.db
+1 -5
View File
@@ -28,9 +28,6 @@ const users = defineTable({
githubFetchedAt: v.optional(v.number()),
githubProfileSyncedAt: v.optional(v.number()),
trustedPublisher: v.optional(v.boolean()),
publishedSkills: v.optional(v.number()),
totalStars: v.optional(v.number()),
totalDownloads: v.optional(v.number()),
personalPublisherId: v.optional(v.id("publishers")),
requiresModerationAt: v.optional(v.number()),
requiresModerationReason: v.optional(v.string()),
@@ -388,8 +385,7 @@ const souls = defineTable({
.index("by_slug", ["slug"])
.index("by_owner", ["ownerUserId"])
.index("by_owner_publisher", ["ownerPublisherId"])
.index("by_updated", ["updatedAt"])
.index("by_active_updated", ["softDeletedAt", "updatedAt"]);
.index("by_updated", ["updatedAt"]);
const skillVersions = defineTable({
skillId: v.id("skills"),
+9 -17
View File
@@ -218,9 +218,6 @@ export const seedDemoSkills = internalMutation({
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];
@@ -298,10 +295,6 @@ export const seedDemoSkills = internalMutation({
tags: { latest: versionId },
});
totalPublishedSkills += 1;
totalStars += s.stars;
totalDownloads += s.downloads;
// Create digest for search
await ctx.db.insert("skillSearchDigest", {
skillId,
@@ -327,11 +320,16 @@ export const seedDemoSkills = internalMutation({
installsCurrent: Math.floor(s.installs * 0.3),
installsAllTime: s.installs,
stars: s.stars,
versions: numVersions,
comments: numComments,
versions: Math.floor(Math.random() * 8) + 1,
comments: Math.floor(Math.random() * 15),
},
versions: numVersions,
comments: numComments,
statsDownloads: s.downloads,
statsStars: s.stars,
statsInstallsCurrent: Math.floor(s.installs * 0.3),
statsInstallsAllTime: s.installs,
softDeletedAt: undefined,
moderationStatus: "active",
moderationFlags: undefined,
moderationReason: undefined,
isSuspicious: false,
createdAt,
@@ -339,12 +337,6 @@ export const seedDemoSkills = internalMutation({
});
}
await ctx.db.patch(seedUserId, {
publishedSkills: totalPublishedSkills,
totalStars,
totalDownloads,
});
return { seeded: true, count: DEMO_SKILLS.length };
},
});
-2
View File
@@ -23,7 +23,6 @@ import type { Doc, Id } from "./_generated/dataModel";
import type { MutationCtx } from "./_generated/server";
import { internalAction, internalMutation, internalQuery } from "./functions";
import { applySkillStatDeltas, bumpDailySkillStats } from "./lib/skillStats";
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
/**
* Event types that affect skill stats:
@@ -260,7 +259,6 @@ export const processSkillStatEventsInternal = internalMutation({
// Don't update `updatedAt` — stat changes shouldn't move the
// skill's position in the by_active_updated index.
await ctx.db.patch(skill._id, patch);
await adjustUserSkillStatsForSkillChange(ctx, skill, { ...skill, ...patch });
}
// NOTE: Daily stats (skillDailyStats) are written by the 15-minute
+1 -123
View File
@@ -5,10 +5,7 @@ vi.mock("@convex-dev/auth/server", () => ({
authTables: {},
}));
import {
getActiveSkillBatchForStaticScanBackfillInternal,
getPendingScanSkillsInternal,
} from "./skills";
import { getPendingScanSkillsInternal } from "./skills";
type PendingScanResult = Array<{
skillId: string;
@@ -28,17 +25,6 @@ const getPendingScanSkillsHandler = (
>
)._handler;
const getStaticScanBackfillBatchHandler = (
getActiveSkillBatchForStaticScanBackfillInternal as unknown as WrappedHandler<
Record<string, unknown>,
{
skills: Array<{ skillId: string; versionId: string; slug: string }>;
nextCursor: number;
done: boolean;
}
>
)._handler;
describe("skills.getPendingScanSkillsInternal", () => {
it("includes unresolved VT records from the oldest slice and skips finalized ones", async () => {
const recentSkills = [
@@ -229,114 +215,6 @@ describe("skills.getPendingScanSkillsInternal", () => {
});
});
describe("skills.getActiveSkillBatchForStaticScanBackfillInternal", () => {
it("includes latest active skills with missing or stale static scan engine versions", async () => {
const skills = [
{
_id: "skills:missing-static",
_creationTime: 10,
softDeletedAt: undefined,
moderationStatus: "active",
latestVersionId: "skillVersions:missing-static",
slug: "missing-static",
},
{
_id: "skills:stale-static",
_creationTime: 20,
softDeletedAt: undefined,
moderationStatus: "active",
latestVersionId: "skillVersions:stale-static",
slug: "stale-static",
},
{
_id: "skills:current-static",
_creationTime: 30,
softDeletedAt: undefined,
moderationStatus: "active",
latestVersionId: "skillVersions:current-static",
slug: "current-static",
},
{
_id: "skills:hidden-static",
_creationTime: 40,
softDeletedAt: undefined,
moderationStatus: "hidden",
latestVersionId: "skillVersions:hidden-static",
slug: "hidden-static",
},
];
const versions = new Map<string, unknown>([
["skillVersions:missing-static", { _id: "skillVersions:missing-static" }],
[
"skillVersions:stale-static",
{
_id: "skillVersions:stale-static",
staticScan: { engineVersion: "v2.2.0" },
},
],
[
"skillVersions:current-static",
{
_id: "skillVersions:current-static",
staticScan: { engineVersion: "v2.4.0" },
},
],
[
"skillVersions:hidden-static",
{
_id: "skillVersions:hidden-static",
staticScan: { engineVersion: "v2.2.0" },
},
],
]);
const ctx = {
db: {
query: vi.fn((table: string) => {
if (table !== "skills") throw new Error(`unexpected table ${table}`);
return {
withIndex: (
indexName: string,
builder: (q: { gt: (field: string, value: unknown) => unknown }) => unknown,
) => {
builder({ gt: () => ({}) });
if (indexName !== "by_creation_time") {
throw new Error(`unexpected index ${indexName}`);
}
return {
order: () => ({
take: async () => skills,
}),
};
},
};
}),
get: vi.fn(async (id: string) => versions.get(id) ?? null),
},
};
const result = await getStaticScanBackfillBatchHandler(ctx, {
batchSize: 10,
cursor: 0,
});
expect(result.skills).toEqual([
{
skillId: "skills:missing-static",
versionId: "skillVersions:missing-static",
slug: "missing-static",
},
{
skillId: "skills:stale-static",
versionId: "skillVersions:stale-static",
slug: "stale-static",
},
]);
expect(result.done).toBe(true);
});
});
function makeSkill(
id: string,
versionId: string,
-72
View File
@@ -35,12 +35,6 @@ const getBySlugHandler = (
image: string | null;
bio?: string | null;
} | null;
latestVersion?: {
files?: Array<{
path: string;
contentType?: string;
}>;
} | null;
} | null
>
)._handler;
@@ -177,70 +171,4 @@ 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",
}),
]);
});
});
+4 -228
View File
@@ -1,5 +1,4 @@
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";
@@ -39,7 +38,6 @@ import { deriveModerationFlags } from "./lib/moderation";
import { buildModerationSnapshot } from "./lib/moderationEngine";
import {
legacyFlagsFromVerdict,
MODERATION_ENGINE_VERSION,
summarizeReasonCodes,
verdictFromCodes,
} from "./lib/moderationReasonCodes";
@@ -75,7 +73,6 @@ import {
publishVersionForUser,
queueHighlightedWebhook,
} from "./lib/skillPublish";
import { runStaticPublishScan } from "./lib/staticPublishScan";
import { getFrontmatterValue, hashSkillFiles } from "./lib/skills";
import { computeIsSuspicious, isSkillSuspicious } from "./lib/skillSafety";
import {
@@ -84,7 +81,6 @@ import {
extractDigestFields,
upsertSkillSearchDigest,
} from "./lib/skillSearchDigest";
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
import schema from "./schema";
export { publishVersionForUser } from "./lib/skillPublish";
@@ -463,7 +459,7 @@ async function syncSkillModerationFromLatestVersion(
function buildConflictingSkillUrl(
skill: Doc<"skills">,
owner: SkillOwnerRef,
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
) {
if (!owner || owner.deletedAt || owner.deactivatedAt || !isPublicSkillDoc(skill)) return null;
const ownerParam = owner.handle?.trim() || String(owner._id);
@@ -473,7 +469,7 @@ function buildConflictingSkillUrl(
function buildSlugTakenErrorMessage(
skill: Doc<"skills">,
owner: SkillOwnerRef,
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
) {
if (!owner || owner.deletedAt || owner.deactivatedAt) {
return (
@@ -489,7 +485,7 @@ function buildSlugTakenErrorMessage(
function buildAliasTakenErrorMessage(
skill: Doc<"skills">,
owner: SkillOwnerRef,
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
) {
const base = "Slug redirects to an existing skill. Choose a different slug.";
const url = buildConflictingSkillUrl(skill, owner);
@@ -501,16 +497,6 @@ function normalizeSkillSlugKey(slug: string) {
return slug.trim().toLowerCase();
}
type SkillOwnerRef =
| {
_id: Id<"users"> | Id<"publishers">;
handle?: string | null;
deletedAt?: number | null;
deactivatedAt?: number | null;
}
| null
| undefined;
function normalizeSkillSlugForWrite(slug: string) {
const normalized = normalizeSkillSlugKey(slug);
if (!normalized || !/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
@@ -759,7 +745,6 @@ async function hardDeleteSkillStep(
const nextSkill = { ...skill, ...patch };
await ctx.db.patch(skill._id, patch);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
}
switch (phase) {
@@ -1232,7 +1217,7 @@ function toPublicSkillVersion(
path: file.path,
size: file.size,
sha256: file.sha256,
contentType: normalizeTextContentType(file.path, file.contentType),
contentType: file.contentType,
})),
parsed: version.parsed
? {
@@ -2531,7 +2516,6 @@ export const report = mutation({
const nextSkill = { ...skill, ...updates };
await ctx.db.patch(skill._id, updates);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
if (shouldAutoHide) {
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, now);
@@ -3699,56 +3683,6 @@ export const getActiveSkillBatchForLlmBackfillInternal = internalQuery({
},
});
/**
* Get active latest skill versions whose static scan is missing or uses an older engine version.
* Used to backfill new static rules onto already-published skills.
*/
export const getActiveSkillBatchForStaticScanBackfillInternal = internalQuery({
args: {
cursor: v.optional(v.number()),
batchSize: v.optional(v.number()),
},
handler: async (ctx, args) => {
const batchSize = args.batchSize ?? 25;
const cursor = args.cursor ?? 0;
const candidates = await ctx.db
.query("skills")
.withIndex("by_creation_time", (q) => q.gt("_creationTime", cursor))
.order("asc")
.take(batchSize * 4);
const results: Array<{
skillId: Id<"skills">;
versionId: Id<"skillVersions">;
slug: string;
}> = [];
let nextCursor = cursor;
for (const skill of candidates) {
nextCursor = skill._creationTime;
if (results.length >= batchSize) break;
if (skill.softDeletedAt) continue;
if ((skill.moderationStatus ?? "active") !== "active") continue;
if (!skill.latestVersionId) continue;
const version = await ctx.db.get(skill.latestVersionId);
if (!version) continue;
if (version.staticScan?.engineVersion === MODERATION_ENGINE_VERSION) continue;
results.push({
skillId: skill._id,
versionId: version._id,
slug: skill.slug,
});
}
const done = candidates.length < batchSize * 4;
return { skills: results, nextCursor, done };
},
});
/**
* Get skills with stale moderationReason that have vtAnalysis cached.
* Used to sync moderationReason with cached VT results.
@@ -3847,159 +3781,6 @@ export const getPendingVTSkillsInternal = internalQuery({
},
});
export const updateSkillVersionStaticScanInternal = internalMutation({
args: {
skillId: v.id("skills"),
versionId: v.id("skillVersions"),
staticScan: v.object({
status: v.union(v.literal("clean"), v.literal("suspicious"), v.literal("malicious")),
reasonCodes: v.array(v.string()),
findings: v.array(
v.object({
code: v.string(),
severity: v.union(v.literal("info"), v.literal("warn"), v.literal("critical")),
file: v.string(),
line: v.number(),
message: v.string(),
evidence: v.string(),
}),
),
summary: v.string(),
engineVersion: v.string(),
checkedAt: v.number(),
}),
},
handler: async (ctx, args) => {
const version = await ctx.db.get(args.versionId);
if (!version || version.skillId !== args.skillId) return { ok: true as const, skipped: "missing" as const };
await ctx.db.patch(version._id, {
staticScan: args.staticScan,
});
const skill = await ctx.db.get(args.skillId);
if (!skill) return { ok: true as const, skipped: "missing" as const };
if (skill.latestVersionId !== version._id) {
return { ok: true as const, skipped: "not_latest" as const };
}
const owner = skill.ownerUserId ? await ctx.db.get(skill.ownerUserId) : null;
const now = Date.now();
const updatedVersion = { ...version, staticScan: args.staticScan };
const basePatch = buildScannerModerationPatchFromVersion({
owner,
version: updatedVersion,
now,
});
const patch = applySkillManualOverrideToSkillPatch({
skill,
basePatch: {
...basePatch,
updatedAt: now,
},
now,
});
const nextSkill = { ...skill, ...patch };
await ctx.db.patch(skill._id, patch);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
if (patch.moderationVerdict === "malicious" && skill.ownerUserId) {
await ctx.scheduler.runAfter(0, internal.users.placeUserUnderModerationInternal, {
ownerUserId: skill.ownerUserId,
slug: skill.slug,
reason:
patch.moderationReasonCodes?.find((code) => code.startsWith("malicious.")) ??
"malicious.static_scan",
});
}
return { ok: true as const, status: args.staticScan.status };
},
});
export const scanSkillVersionStaticallyInternal: ReturnType<typeof internalAction> = internalAction({
args: {
skillId: v.id("skills"),
versionId: v.id("skillVersions"),
},
handler: async (ctx, args) => {
const [skill, version] = await Promise.all([
ctx.runQuery(internal.skills.getSkillByIdInternal, { skillId: args.skillId }),
ctx.runQuery(internal.skills.getVersionByIdInternal, { versionId: args.versionId }),
]);
if (!skill || !version) {
return { ok: true as const, skipped: "missing" as const };
}
const staticScan = await runStaticPublishScan(ctx, {
slug: skill.slug,
displayName: skill.displayName,
summary: skill.summary ?? undefined,
frontmatter: version.parsed?.frontmatter ?? {},
metadata: version.parsed?.metadata,
files: version.files,
});
return await ctx.runMutation(internal.skills.updateSkillVersionStaticScanInternal, {
skillId: skill._id,
versionId: version._id,
staticScan,
});
},
});
export const backfillSkillStaticScansInternal: ReturnType<typeof internalAction> = internalAction({
args: {
cursor: v.optional(v.number()),
batchSize: v.optional(v.number()),
rescanned: v.optional(v.number()),
},
handler: async (ctx, args) => {
const batchSize = Math.max(1, Math.min(args.batchSize ?? 25, 100));
const batch = await ctx.runQuery(internal.skills.getActiveSkillBatchForStaticScanBackfillInternal, {
cursor: args.cursor,
batchSize,
});
let rescanned = args.rescanned ?? 0;
for (const skill of batch.skills) {
await ctx.scheduler.runAfter(0, internal.skills.scanSkillVersionStaticallyInternal, {
skillId: skill.skillId,
versionId: skill.versionId,
});
rescanned += 1;
}
if (!batch.done) {
await ctx.scheduler.runAfter(0, internal.skills.backfillSkillStaticScansInternal, {
cursor: batch.nextCursor,
batchSize,
rescanned,
});
}
return {
rescanned,
nextCursor: batch.nextCursor,
done: batch.done,
};
},
});
export const backfillSkillStaticScans: ReturnType<typeof action> = action({
args: {
batchSize: v.optional(v.number()),
},
handler: async (ctx, args) => {
const { user } = await requireUserFromAction(ctx);
assertAdmin(user);
return await ctx.runAction(internal.skills.backfillSkillStaticScansInternal, {
batchSize: args.batchSize,
});
},
});
/**
* Emergency escalation by skillId for legacy rows without sha256hash.
* Rebuilds the full moderation snapshot so legacy rows stay in sync with structured fields.
@@ -4258,7 +4039,6 @@ export const applyBanToOwnedSkillsBatchInternal = internalMutation({
const nextSkill = { ...skill, ...patch };
await ctx.db.patch(skill._id, patch);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, args.bannedAt);
}
@@ -4370,7 +4150,6 @@ export const restoreOwnedSkillsForUnbanBatchInternal = internalMutation({
const nextSkill = { ...skill, ...patch };
await ctx.db.patch(skill._id, patch);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, false, now);
restoredCount += 1;
@@ -5310,7 +5089,6 @@ export const setSoftDeleted = mutation({
const nextSkill = { ...skill, ...patch };
await ctx.db.patch(skill._id, patch);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, args.deleted, now);
@@ -5349,7 +5127,6 @@ export const changeOwner = mutation({
lastReviewedAt: now,
updatedAt: now,
});
await adjustUserSkillStatsForSkillChange(ctx, skill, { ...skill, ownerUserId: args.ownerUserId });
const embeddings = await listSkillEmbeddingsForSkill(ctx, skill._id);
for (const embedding of embeddings) {
@@ -6430,7 +6207,6 @@ export const insertVersion = internalMutation({
// Digest sync is handled after the version patch below (line ~4222),
// which captures the final state including latestVersionId and tags.
await adjustGlobalPublicCountForSkillChange(ctx, null, skill);
await adjustUserSkillStatsForSkillChange(ctx, null, skill);
}
}
+1 -74
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { getSoulBySlugInternal, insertVersion, list } from "./souls";
import { getSoulBySlugInternal, insertVersion } from "./souls";
type WrappedHandler<TArgs> = {
_handler: (ctx: unknown, args: TArgs) => Promise<unknown>;
@@ -10,7 +10,6 @@ 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 () => {
@@ -140,75 +139,3 @@ 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",
}),
]);
});
});
+3 -2
View File
@@ -138,10 +138,11 @@ export const list = query({
}
const entries = await ctx.db
.query("souls")
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
.order("desc")
.take(limit);
.take(limit * 5);
return entries
.filter((soul) => !soul.softDeletedAt)
.slice(0, limit)
.map((soul) => toPublicSoul(soul))
.filter((soul): soul is NonNullable<typeof soul> => Boolean(soul));
},
+1 -3
View File
@@ -34,8 +34,6 @@ export const toggle = mutation({
return { starred: false };
}
if (skill.softDeletedAt) throw new Error("Skill not found");
await ctx.db.insert("stars", {
skillId: args.skillId,
userId,
@@ -72,7 +70,7 @@ export const addStarInternal = internalMutation({
args: { userId: v.id("users"), skillId: v.id("skills") },
handler: async (ctx, args) => {
const skill = await ctx.db.get(args.skillId);
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
if (!skill) throw new Error("Skill not found");
const existing = await ctx.db
.query("stars")
.withIndex("by_skill_user", (q) => q.eq("skillId", args.skillId).eq("userId", args.userId))
+1 -4
View File
@@ -230,11 +230,8 @@ export const reconcileSkillStarCounts = internalMutation({
.order("asc")
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
let scanned = 0;
let patched = 0;
for (const skill of page) {
if (skill.softDeletedAt) continue;
scanned += 1;
// Count actual star records for this skill
const starRecords = await ctx.db
.query("stars")
@@ -266,7 +263,7 @@ export const reconcileSkillStarCounts = internalMutation({
}
return {
scanned,
scanned: page.length,
patched,
cursor: isDone ? null : continueCursor,
isDone,
-75
View File
@@ -1075,81 +1075,6 @@ 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",
+25 -4
View File
@@ -471,12 +471,33 @@ export const getByHandle = query({
export const getHoverStats = query({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
const skills = [];
let cursor: string | null = null;
for (;;) {
const page = await ctx.db
.query("skills")
.withIndex("by_owner", (q) => q.eq("ownerUserId", args.userId))
.paginate({ cursor, numItems: 100 });
skills.push(...page.page);
if (page.isDone) {
break;
}
cursor = page.continueCursor;
}
const active = skills.filter((s) => !s.softDeletedAt);
let totalStars = 0;
let totalDownloads = 0;
for (const s of active) {
totalStars += s.stats?.stars ?? 0;
totalDownloads += s.stats?.downloads ?? 0;
}
return {
publishedSkills: user?.publishedSkills ?? 0,
totalStars: user?.totalStars ?? 0,
totalDownloads: user?.totalDownloads ?? 0,
publishedSkills: active.length,
totalStars,
totalDownloads,
};
},
});
-3
View File
@@ -62,9 +62,6 @@ 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
+1 -8
View File
@@ -7,7 +7,6 @@
"type": "module",
"scripts": {
"build": "bun --bun vite build && bun scripts/copy-og-assets.ts",
"check": "bun run lint",
"check:peers": "bun scripts/check-peer-deps.ts",
"check:secrets": "bun scripts/check-staged-secrets.mjs",
"convex:deploy": "bunx convex deploy --typecheck=disable --yes",
@@ -46,8 +45,6 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -60,7 +57,6 @@
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "1.167.2",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clawhub-schema": "workspace:*",
"clsx": "^2.1.1",
"convex": "^1.34.1",
@@ -70,8 +66,6 @@
"ignore": "^7.0.5",
"lucide-react": "^0.577.0",
"monaco-editor": "^0.55.1",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"nitro": "3.0.260311-beta",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -82,7 +76,6 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"vite-tsconfig-paths": "^6.1.1",
"yaml": "^2.8.3",
"zod": "^4.3.6"
@@ -105,7 +98,7 @@
"oxlint-tsgolint": "^0.17.4",
"typescript": "^5.9.3",
"undici": "^7.24.7",
"vite": "8.0.5",
"vite": "8.0.1",
"vitest": "^4.1.2"
}
}
+1 -6
View File
@@ -27,12 +27,7 @@ clawhub login --token clh_...
Notes:
- Browser login opens `https://clawhub.ai/cli/auth` and completes via a loopback callback.
- 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`).
- Token stored in `~/Library/Application Support/clawhub/config.json` on macOS (override via `CLAWHUB_CONFIG_PATH`, legacy `CLAWDHUB_CONFIG_PATH`).
## Examples
-9
View File
@@ -57,14 +57,6 @@ 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?",
@@ -196,7 +188,6 @@ 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,13 +46,6 @@ 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;
@@ -268,12 +261,6 @@ 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,12 +40,6 @@ 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?",
@@ -157,7 +151,6 @@ 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,5 +3,3 @@ 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,26 +53,6 @@ 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;
@@ -83,19 +63,4 @@ 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,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"}
{"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"}
-9
View File
@@ -57,14 +57,6 @@ 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?",
@@ -198,7 +190,6 @@ 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",
+1 -23
View File
@@ -2,12 +2,7 @@
import { describe, expect, it } from "vitest";
import * as schema from ".";
import {
guessTextContentType,
isTextContentType,
normalizeTextContentType,
TEXT_FILE_EXTENSION_SET,
} from "./textFiles";
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from "./textFiles";
describe("clawhub-schema textFiles", () => {
it("exports text-file extension set", () => {
@@ -21,25 +16,8 @@ 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,27 +57,6 @@ 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() ?? "";
@@ -85,17 +64,3 @@ 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;
}
+34 -47
View File
@@ -1,24 +1,15 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import Header from "../components/Header";
const siteModeMock = vi.fn(() => "souls");
const convexQueryMock = vi.fn().mockResolvedValue(0);
vi.mock("@tanstack/react-router", () => ({
Link: (props: {
children: ReactNode;
className?: string;
hash?: string;
to?: string;
}) => (
<a href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`} className={props.className}>
{props.children}
</a>
),
useLocation: () => ({ pathname: "/" }),
Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
useNavigate: () => vi.fn(),
}));
@@ -39,20 +30,11 @@ 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",
setTheme: setThemeMock,
setMode: setModeMock,
setMode: vi.fn(),
}),
}));
@@ -84,10 +66,32 @@ vi.mock("../lib/site", () => ({
getSiteName: () => "OnlyCrabs",
}));
vi.mock("../lib/convexError", () => ({
getUserFacingConvexError: vi.fn(),
}));
vi.mock("../lib/gravatar", () => ({
gravatarUrl: vi.fn(),
}));
vi.mock("../convex/client", () => ({
convexHttp: {
query: convexQueryMock,
},
}));
vi.mock("../../convex/_generated/api", () => ({
api: {
skills: {
countPublicSkills: "countPublicSkills",
},
},
}));
vi.mock("../lib/numberFormat", () => ({
formatCompactStat: (n: number) => String(n),
}));
vi.mock("../components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
@@ -98,7 +102,9 @@ 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", () => {
@@ -110,34 +116,15 @@ describe("Header", () => {
expect(screen.queryByText("Packages")).toBeNull();
});
it("renders direct desktop theme family controls and plain Skills tab", () => {
it("renders a plain Skills tab without fetching a count", () => {
siteModeMock.mockReturnValue("skills");
setThemeMock.mockClear();
setModeMock.mockClear();
convexQueryMock.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);
});
});
expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy();
expect(convexQueryMock).not.toHaveBeenCalled();
});});
@@ -277,12 +277,6 @@ 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();
});
+64 -217
View File
@@ -1,7 +1,7 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { Link, 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 { useMemo, useRef, useState } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import { gravatarUrl } from "../lib/gravatar";
import {
@@ -12,7 +12,7 @@ import {
} from "../lib/nav-items";
import { isModerator } from "../lib/roles";
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
import { applyTheme, THEME_OPTIONS, useThemeMode } from "../lib/theme";
import { applyTheme, useThemeMode } from "../lib/theme";
import { startThemeTransition } from "../lib/theme-transition";
import { setAuthError, useAuthError } from "../lib/useAuthError";
import { useAuthStatus } from "../lib/useAuthStatus";
@@ -24,40 +24,24 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
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 }>> = {
const NAV_ICONS: Record<NavIconName, React.ComponentType<{ size?: number; className?: string }>> = {
wrench: Wrench,
plug: Plug,
ghost: Ghost,
};
const THEME_FAMILY_ICONS: Record<string, ComponentType<{ size?: number; className?: string }>> = {
claw: Ghost,
hub: Plug,
};
const THEME_MODE_SEQUENCE: Array<"system" | "light" | "dark"> = ["system", "light", "dark"];
export default function Header() {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const { signIn, signOut } = useAuthActions();
const { theme, mode, setMode, setTheme } = useThemeMode();
const { mode, setMode } = useThemeMode();
const toggleRef = useRef<HTMLDivElement | null>(null);
const siteMode = getSiteMode();
const siteName = useMemo(() => getSiteName(siteMode), [siteMode]);
const isSoulMode = siteMode === "souls";
const clawHubUrl = getClawHubSiteUrl();
const navigate = useNavigate();
const location = useLocation();
const avatar = me?.image ?? (me?.email ? gravatarUrl(me.email) : undefined);
const handle = me?.handle ?? me?.displayName ?? "user";
@@ -75,44 +59,20 @@ export default function Header() {
const [navSearchQuery, setNavSearchQuery] = useState("");
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const themeLabel = THEME_OPTIONS.find((option) => option.value === theme)?.label ?? "Claw";
const ThemeFamilyIcon = THEME_FAMILY_ICONS[theme] ?? Wrench;
const ThemeModeIcon = getThemeModeIcon(mode);
const setThemeMode = (next: "system" | "light" | "dark") => {
const setTheme = (next: "system" | "light" | "dark") => {
startThemeTransition({
nextTheme: next,
currentTheme: mode,
setTheme: (value) => {
const nextMode = value as "system" | "light" | "dark";
applyTheme(nextMode, theme);
applyTheme(nextMode);
setMode(nextMode);
},
context: { element: toggleRef.current },
});
};
const setThemeFamily = (nextTheme: string) => {
applyTheme(mode, nextTheme);
setTheme(nextTheme);
};
const cycleThemeFamily = () => {
const currentIndex = Math.max(
0,
THEME_OPTIONS.findIndex((option) => option.value === theme),
);
const nextTheme = THEME_OPTIONS[(currentIndex + 1) % THEME_OPTIONS.length]?.value ?? "claw";
setThemeFamily(nextTheme);
};
const cycleThemeMode = () => {
const currentIndex = Math.max(0, THEME_MODE_SEQUENCE.indexOf(mode));
const nextMode = THEME_MODE_SEQUENCE[(currentIndex + 1) % THEME_MODE_SEQUENCE.length] ?? "system";
setThemeMode(nextMode);
};
const handleNavSearch = (e: React.FormEvent) => {
e.preventDefault();
const q = navSearchQuery.trim();
@@ -130,112 +90,15 @@ export default function Header() {
<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>
<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);
}}
>
<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>
</SheetContent>
</Sheet>
</div>
<Link
to="/"
search={{ q: undefined, highlighted: undefined, search: undefined }}
className="brand"
>
<span className="brand-mark">
<img src="/clawd-logo.png" alt="" aria-hidden="true" className="brand-mark-image" />
<img src="/clawd-logo.png" alt="" aria-hidden="true" />
</span>
<span className="brand-name brand-name-responsive">{siteName}</span>
<span className="brand-name">{siteName}</span>
</Link>
<form className="navbar-search" onSubmit={handleNavSearch} role="search" aria-label="Site search">
@@ -259,52 +122,58 @@ export default function Header() {
>
<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 className="nav-mobile">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="nav-mobile-trigger" type="button" aria-label="Open menu">
<Menu className="h-4 w-4" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isSoulMode ? (
<DropdownMenuItem asChild>
<a href={clawHubUrl}>ClawHub</a>
</DropdownMenuItem>
) : null}
{primaryItems.map((item) => (
<DropdownMenuItem key={item.to + item.label} asChild>
<Link to={item.to} search={item.search ?? {}}>
{item.label}
</Link>
</DropdownMenuItem>
))}
</div>
</div>
<div className="theme-cycle-group" aria-label="Theme controls">
<button
type="button"
className="theme-cycle-button theme-cycle-button-family"
onClick={cycleThemeFamily}
aria-label={`Cycle theme family. Current: ${themeLabel}`}
title={`Theme family: ${themeLabel}`}
>
<ThemeFamilyIcon className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
className="theme-cycle-button theme-cycle-button-mode"
onClick={cycleThemeMode}
aria-label={`Cycle theme mode. Current: ${mode}`}
title={`Theme mode: ${mode}`}
>
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{secondaryItems.map((item) => (
<DropdownMenuItem key={item.to + item.label} asChild>
<Link to={item.to} search={item.search ?? {}}>
{item.label}
</Link>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="h-4 w-4" aria-hidden="true" />
System
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="h-4 w-4" aria-hidden="true" />
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="h-4 w-4" aria-hidden="true" />
Dark
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="theme-toggle" ref={toggleRef}>
<ToggleGroup
className="theme-mode-toggle"
type="single"
value={mode}
onValueChange={(value) => {
if (!value) return;
setThemeMode(value as "system" | "light" | "dark");
setTheme(value as "system" | "light" | "dark");
}}
aria-label={`Theme mode, ${themeLabel} preset`}
aria-label="Theme mode"
>
<ToggleGroupItem value="system" aria-label="System theme">
<Monitor className="h-4 w-4" aria-hidden="true" />
@@ -408,16 +277,12 @@ export default function Header() {
) : 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}
@@ -426,22 +291,16 @@ export default function Header() {
})}
</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>
);
})}
{secondaryItems.map((item) => (
<Link
key={item.to + item.label}
to={item.to}
search={item.search ?? {}}
className="navbar-tab navbar-tab-secondary"
>
{item.label === "Management" ? "Manage" : item.label}
</Link>
))}
</div>
</nav>
</div>
@@ -453,15 +312,3 @@ 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 -9
View File
@@ -8,9 +8,6 @@ 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;
@@ -158,12 +155,7 @@ 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={OPENCLAW_PLUGIN_PACKAGE_METADATA_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
<a href="/plugins/sdk-setup#package-metadata" className="underline">
Plugin Setup and Config
</a>
.
+69 -67
View File
@@ -401,76 +401,78 @@ export function SkillDetailPage({
/>
) : 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>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Install via Nix
</h3>
<pre className="hero-install-code mt-2">
{nixSnippet}
</pre>
</Card>
) : null}
{configExample ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Config example
</h3>
<pre className="hero-install-code mt-2">
{configExample}
</pre>
</Card>
) : null}
<SkillDetailTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
onCompareIntent={() => setShouldPrefetchCompare(true)}
readmeContent={readmeContent}
readmeError={readmeError}
latestFiles={latestFiles}
latestVersionId={latestVersion?._id ?? null}
skill={skill as Doc<"skills">}
diffVersions={diffVersions}
versions={versions}
nixPlugin={Boolean(nixPlugin)}
suppressVersionScanResults={suppressVersionScanResults}
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
/>
<ClientOnly
fallback={
<div className="detail-layout">
<div className="detail-main">
{nixSnippet ? (
<Card>
<h2 className="section-title text-[1.2rem] m-0">
Comments
</h2>
<p className="section-subtitle mt-3 mb-0">
Loading comments...
</p>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Install via Nix
</h3>
<pre className="hero-install-code mt-2">
{nixSnippet}
</pre>
</Card>
}
>
<SkillCommentsPanel
skillId={skill._id}
isAuthenticated={isAuthenticated}
me={me ?? null}
) : null}
{configExample ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Config example
</h3>
<pre className="hero-install-code mt-2">
{configExample}
</pre>
</Card>
) : null}
<SkillDetailTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
onCompareIntent={() => setShouldPrefetchCompare(true)}
readmeContent={readmeContent}
readmeError={readmeError}
latestFiles={latestFiles}
latestVersionId={latestVersion?._id ?? null}
skill={skill as Doc<"skills">}
diffVersions={diffVersions}
versions={versions}
nixPlugin={Boolean(nixPlugin)}
suppressVersionScanResults={suppressVersionScanResults}
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
/>
</ClientOnly>
<ClientOnly
fallback={
<Card>
<h2 className="section-title text-[1.2rem] m-0">
Comments
</h2>
<p className="section-subtitle mt-3 mb-0">
Loading comments...
</p>
</Card>
}
>
<SkillCommentsPanel
skillId={skill._id}
isAuthenticated={isAuthenticated}
me={me ?? null}
/>
</ClientOnly>
</div>
<SkillMetadataSidebar
skill={skill}
latestVersion={latestVersion}
owner={owner}
ownerHandle={ownerHandle}
clawdis={clawdis}
osLabels={osLabels}
tagEntries={tagEntries}
isMalwareBlocked={modInfo?.isMalwareBlocked}
isRemoved={modInfo?.isRemoved}
nixPlugin={nixPlugin}
/>
</div>
</div>
+8 -12
View File
@@ -13,7 +13,6 @@ import {
selectDefaultFilePath,
sortVersionsBySemver,
} from "../lib/diffing";
import { isDarkThemeResolved, onThemeChange } from "../lib/theme";
import { Button } from "./ui/button";
import { ClientOnly } from "./ClientOnly";
@@ -233,18 +232,15 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
useEffect(() => {
if (!monaco || typeof document === "undefined") return;
const syncTheme = () => applyMonacoTheme(monaco);
const observer = new MutationObserver(syncTheme);
const observer = new MutationObserver(() => {
applyMonacoTheme(monaco);
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "data-theme-family", "data-theme-resolved"],
attributeFilter: ["data-theme"],
});
const removeThemeListener = onThemeChange(syncTheme);
syncTheme();
return () => {
observer.disconnect();
removeThemeListener();
};
applyMonacoTheme(monaco);
return () => observer.disconnect();
}, [monaco]);
useEffect(() => {
@@ -443,7 +439,7 @@ function renderOptions(options: VersionOption[]) {
function getMonacoThemeName() {
if (typeof document === "undefined") return "clawhub-light";
return isDarkThemeResolved() ? "clawhub-dark" : "clawhub-light";
return document.documentElement.dataset.theme === "dark" ? "clawhub-dark" : "clawhub-light";
}
function buildDiffOptions(viewMode: "split" | "inline"): DiffEditorProps["options"] {
@@ -479,7 +475,7 @@ function applyMonacoTheme(monaco: NonNullable<ReturnType<typeof useMonaco>>) {
const diffDiagonal = styles.getPropertyValue("--diff-diagonal").trim() || "#22222233";
const background = surface;
const gutter = surfaceMuted;
const isDark = isDarkThemeResolved();
const isDark = document.documentElement.dataset.theme === "dark";
const base = isDark ? "vs-dark" : "vs";
const diffInserted = withAlpha(diffAdded, isDark ? 0.22 : 0.2);
+82 -61
View File
@@ -3,7 +3,7 @@ import {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_SUMMARY,
} from "clawhub-schema/licenseConstants";
import { Calendar, Download, Package, Scale, Star, Tag } from "lucide-react";
import { Package, Star } from "lucide-react";
import type { Id } from "../../convex/_generated/dataModel";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
@@ -39,83 +39,104 @@ export function SkillMetadataSidebar({
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>
<aside className="detail-sidebar">
{/* Download / Install */}
{!nixPlugin && !isMalwareBlocked && !isRemoved ? (
<div className="sidebar-card">
<h3 className="sidebar-card-title">Download</h3>
<Button asChild variant="primary" className="w-full justify-center">
<a href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}>
Download zip
</a>
</Button>
</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>
) : null}
{/* Stats */}
<div className="sidebar-card">
<h3 className="sidebar-card-title">Stats</h3>
<div className="sidebar-stat-grid">
<div className="sidebar-stat">
<span className="sidebar-stat-value">
<Package size={14} aria-hidden="true" />
{formatCompactStat(skill.stats.downloads)}
</span>
<span className="sidebar-stat-label">Downloads</span>
</div>
<div className="sidebar-stat">
<span className="sidebar-stat-value">
<Star size={14} aria-hidden="true" />
{formatCompactStat(skill.stats.stars)}
</span>
<span className="sidebar-stat-label">Stars</span>
</div>
<div className="sidebar-stat">
<span className="sidebar-stat-value">
{formatCompactStat(skill.stats.versions ?? 0)}
</span>
<span className="sidebar-stat-label">Versions</span>
</div>
</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>
{/* Metadata */}
<div className="sidebar-card">
<h3 className="sidebar-card-title">Details</h3>
<dl className="sidebar-metadata">
<div className="sidebar-metadata-row">
<dt>Updated</dt>
<dd>{timeAgo(skill.updatedAt)}</dd>
</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 className="sidebar-metadata-row">
<dt>Created</dt>
<dd>{timeAgo(skill.createdAt)}</dd>
</div>
) : null}
{latestVersion?.version ? (
<div className="sidebar-metadata-row">
<dt>Version</dt>
<dd>v{latestVersion.version}</dd>
</div>
) : null}
<div className="sidebar-metadata-row">
<dt>License</dt>
<dd>{PLATFORM_SKILL_LICENSE} ({PLATFORM_SKILL_LICENSE_SUMMARY})</dd>
</div>
{osLabels.length ? (
<div className="sidebar-metadata-row">
<dt>Platforms</dt>
<dd>{osLabels.join(", ")}</dd>
</div>
) : null}
</dl>
</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">
{/* Tags */}
{tagEntries.length > 0 ? (
<div className="sidebar-card">
<h3 className="sidebar-card-title">Tags</h3>
<div className="sidebar-tags">
{tagEntries.map(([tag]) => (
<Badge key={tag} variant="compact">
{tag}
</Badge>
))}
</div>
) : null}
</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}
{/* Owner */}
<div className="sidebar-card">
<h3 className="sidebar-card-title">Publisher</h3>
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix=""
size="md"
showName
/>
</div>
</div>
</aside>
);
}
+14 -14
View File
@@ -2,23 +2,23 @@ import * as React from "react";
import { cn } from "../../lib/utils";
interface ContainerProps extends React.HTMLAttributes<HTMLDivElement> {
size?: "default" | "narrow" | "wide";
size?: "default" | "narrow" | "wide";
}
const Container = React.forwardRef<HTMLDivElement, ContainerProps>(
({ className, size = "default", ...props }, ref) => (
<div
ref={ref}
className={cn(
"mx-auto w-full px-4 sm:px-6 lg:px-7",
size === "default" && "max-w-page-max",
size === "narrow" && "max-w-page-narrow",
size === "wide" && "w-full",
className,
)}
{...props}
/>
),
({ className, size = "default", ...props }, ref) => (
<div
ref={ref}
className={cn(
"mx-auto w-full px-4 sm:px-6 lg:px-7",
size === "default" && "max-w-page-max",
size === "narrow" && "max-w-page-narrow",
size === "wide" && "max-w-page-max",
className,
)}
{...props}
/>
),
);
Container.displayName = "Container";
-32
View File
@@ -1,32 +0,0 @@
import * as SwitchPrimitive from "@radix-ui/react-switch";
import * as React from "react";
import { cn } from "../../lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
ref={ref}
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent",
"transition-colors duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
"disabled:cursor-not-allowed disabled:opacity-50",
"data-[state=checked]:bg-[color:var(--accent)] data-[state=unchecked]:bg-[color:var(--surface-muted)]",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm ring-0",
"transition-transform duration-200 ease-out",
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
));
Switch.displayName = SwitchPrimitive.Root.displayName;
export { Switch };
-424
View File
@@ -1,424 +0,0 @@
/**
* Lightweight custom theme support for ClawHub.
*
* Accepts tweakcn theme URLs, bare tweakcn names, tweakcn JSON payloads,
* or raw CSS variable blocks, then maps them into ClawHub's token system.
*/
const SUPPORTED_COLOR_VARS = [
"background",
"foreground",
"card",
"card-foreground",
"popover",
"popover-foreground",
"primary",
"primary-foreground",
"secondary",
"secondary-foreground",
"muted",
"muted-foreground",
"accent",
"accent-foreground",
"destructive",
"destructive-foreground",
"border",
"input",
"ring",
"info",
"info-foreground",
"success",
"success-foreground",
"warning",
"warning-foreground",
] as const;
const SUPPORTED_DESIGN_VARS = ["radius"] as const;
const SUPPORTED_FONT_VARS = ["font-sans", "font-serif", "font-mono"] as const;
const ALL_SUPPORTED_VARS = new Set<string>([
...SUPPORTED_COLOR_VARS,
...SUPPORTED_DESIGN_VARS,
...SUPPORTED_FONT_VARS,
]);
const CUSTOM_THEME_STORAGE_KEY = "clawhub-custom-theme";
const CUSTOM_THEME_STYLE_ID = "clawhub-custom-theme-style";
const CUSTOM_THEME_FONT_LINK_ID = "clawhub-custom-theme-fonts";
const SYSTEM_FONTS = new Set([
"system-ui",
"-apple-system",
"blinkmacsystemfont",
"segoe ui",
"roboto",
"helvetica neue",
"arial",
"sans-serif",
"serif",
"monospace",
"sf mono",
"sfmono-regular",
"consolas",
"liberation mono",
"menlo",
"courier new",
"dm sans",
"georgia",
"times new roman",
"times",
"ui-monospace",
"ui-sans-serif",
"ui-serif",
"bricolage grotesque",
"manrope",
"ibm plex mono",
]);
export interface CustomThemeData {
name?: string | undefined;
source?: string | undefined;
light: Record<string, string>;
dark: Record<string, string>;
}
function hasDom(): boolean {
return (
typeof document !== "undefined" &&
typeof document.getElementById === "function" &&
typeof document.createElement === "function"
);
}
function extractBraceContent(css: string, startAfterBrace: number): string {
let depth = 1;
let i = startAfterBrace;
while (i < css.length && depth > 0) {
if (css[i] === "{") depth++;
else if (css[i] === "}") depth--;
i++;
}
return css.substring(startAfterBrace, i - 1);
}
function extractVariables(block: string): Record<string, string> {
const vars: Record<string, string> = {};
const regex = /--([\w-]+)\s*:\s*([^;]+);/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(block)) !== null) {
const name = match[1]?.trim();
const value = match[2]?.trim();
if (!name || !value || !ALL_SUPPORTED_VARS.has(name)) continue;
vars[name] = value;
}
return vars;
}
function filterSupported(vars: Record<string, string>): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(vars)) {
if (ALL_SUPPORTED_VARS.has(key)) out[key] = value;
}
return out;
}
export function parseThemeCSS(css: string): CustomThemeData {
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, "");
const light: Record<string, string> = {};
const dark: Record<string, string> = {};
const rootRegex = /:root\s*\{/g;
let match: RegExpExecArray | null;
while ((match = rootRegex.exec(cleaned)) !== null) {
const start = match.index + match[0].length;
Object.assign(light, extractVariables(extractBraceContent(cleaned, start)));
}
const darkRegex = /\.dark\s*\{/g;
while ((match = darkRegex.exec(cleaned)) !== null) {
const start = match.index + match[0].length;
Object.assign(dark, extractVariables(extractBraceContent(cleaned, start)));
}
if (Object.keys(light).length === 0 && Object.keys(dark).length === 0) {
const vars = extractVariables(cleaned);
Object.assign(light, vars);
Object.assign(dark, vars);
}
return { light, dark };
}
export function parseTweakcnJSON(json: unknown): CustomThemeData {
if (!json || typeof json !== "object") {
throw new Error("Invalid theme JSON");
}
const obj = json as Record<string, unknown>;
const name = typeof obj.name === "string" ? obj.name : undefined;
const cssVars = obj.cssVars as Record<string, Record<string, string>> | undefined;
if (!cssVars || typeof cssVars !== "object") {
throw new Error('Theme JSON missing "cssVars" object');
}
const light = filterSupported({ ...cssVars.theme, ...cssVars.light });
const dark = filterSupported({ ...cssVars.theme, ...cssVars.dark });
if (Object.keys(light).length === 0 && Object.keys(dark).length === 0) {
throw new Error("No supported theme variables found in JSON");
}
return { name, light, dark };
}
const TWEAKCN_URL_PATTERNS = [
/^https?:\/\/(?:www\.)?tweakcn\.com\/r\/themes\/([^/?#]+)/,
/^https?:\/\/(?:www\.)?tweakcn\.com\/themes\/([^/?#]+)/,
/^https?:\/\/(?:www\.)?tweakcn\.com\/editor\/theme\?theme=([^&#]+)/,
];
export function isTweakcnURL(input: string): boolean {
const trimmed = input.trim();
return TWEAKCN_URL_PATTERNS.some((re) => re.test(trimmed));
}
function extractTweakcnThemeId(url: string): string | null {
const trimmed = url.trim();
for (const re of TWEAKCN_URL_PATTERNS) {
const match = trimmed.match(re);
if (match?.[1]) return match[1];
}
return null;
}
function isBareThemeName(input: string): boolean {
return /^[a-zA-Z0-9][\w-]*$/.test(input);
}
export async function fetchTweakcnTheme(urlOrName: string): Promise<CustomThemeData> {
const themeId = isBareThemeName(urlOrName) ? urlOrName : extractTweakcnThemeId(urlOrName);
if (!themeId) {
throw new Error("Could not extract theme ID from URL");
}
const response = await fetch(`https://tweakcn.com/r/themes/${encodeURIComponent(themeId)}`);
if (!response.ok) {
throw new Error(`Failed to fetch theme: ${response.status} ${response.statusText}`);
}
return parseTweakcnJSON(await response.json());
}
export async function parseThemeInput(input: string): Promise<CustomThemeData> {
const trimmed = input.trim();
if (isTweakcnURL(trimmed)) {
return fetchTweakcnTheme(trimmed);
}
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
return parseTweakcnJSON(JSON.parse(trimmed));
} catch {
// fall through to CSS parsing
}
}
if (isBareThemeName(trimmed)) {
return fetchTweakcnTheme(trimmed);
}
const parsed = parseThemeCSS(trimmed);
if (Object.keys(parsed.light).length === 0 && Object.keys(parsed.dark).length === 0) {
throw new Error("No theme variables found. Paste CSS, a tweakcn theme URL, or a theme name.");
}
return parsed;
}
function extractGoogleFonts(theme: CustomThemeData): string[] {
const fonts = new Set<string>();
for (const mode of [theme.light, theme.dark]) {
for (const key of SUPPORTED_FONT_VARS) {
const value = mode[key];
if (!value) continue;
const families = value.split(",").map((part) => part.trim().replace(/^["']|["']$/g, ""));
for (const family of families) {
if (family && !SYSTEM_FONTS.has(family.toLowerCase())) {
fonts.add(family);
}
}
}
}
return Array.from(fonts);
}
function loadGoogleFonts(fonts: string[]): void {
if (!hasDom()) return;
document.getElementById(CUSTOM_THEME_FONT_LINK_ID)?.remove();
if (fonts.length === 0) return;
const families = fonts.map((font) => `family=${font.replace(/\s+/g, "+")}:wght@300..800`).join("&");
const link = document.createElement("link");
link.id = CUSTOM_THEME_FONT_LINK_ID;
link.rel = "stylesheet";
link.href = `https://fonts.googleapis.com/css2?${families}&display=swap`;
document.head.appendChild(link);
}
function unloadGoogleFonts(): void {
if (!hasDom()) return;
document.getElementById(CUSTOM_THEME_FONT_LINK_ID)?.remove();
}
function getVar(vars: Record<string, string>, key: string, fallback: string): string {
return vars[key] ?? fallback;
}
function buildModeLines(vars: Record<string, string>): string[] {
const background = getVar(vars, "background", "var(--bg)");
const foreground = getVar(vars, "foreground", "var(--ink)");
const card = getVar(vars, "card", background);
const secondary = getVar(vars, "secondary", card);
const muted = getVar(vars, "muted", secondary);
const primary = getVar(vars, "primary", "var(--accent)");
const primaryForeground = getVar(vars, "primary-foreground", "var(--accent-fg)");
const accent = getVar(vars, "accent", primary);
const border = getVar(vars, "border", "var(--line)");
const input = getVar(vars, "input", border);
const ring = getVar(vars, "ring", primary);
const destructive = getVar(vars, "destructive", "var(--status-error-fg)");
const success = getVar(vars, "success", "var(--seafoam)");
const warning = getVar(vars, "warning", "var(--gold)");
const mutedForeground = getVar(vars, "muted-foreground", foreground);
const radius = vars.radius;
const fontSans = vars["font-sans"];
const fontMono = vars["font-mono"];
const lines = [
` --bg: ${background};`,
` --bg-soft: ${muted};`,
` --surface: ${card};`,
` --surface-muted: ${secondary};`,
` --nav-bg: color-mix(in srgb, ${background} 96%, transparent);`,
` --ink: ${foreground};`,
` --ink-soft: ${mutedForeground};`,
` --accent: ${primary};`,
` --accent-fg: ${primaryForeground};`,
` --accent-deep: ${accent};`,
` --accent-subtle: color-mix(in srgb, ${primary} 14%, transparent);`,
` --seafoam: ${success};`,
` --gold: ${warning};`,
` --line: color-mix(in srgb, ${border} 72%, transparent);`,
` --border-ui: color-mix(in srgb, ${border} 90%, transparent);`,
` --border-ui-hover: color-mix(in srgb, ${ring} 50%, ${border});`,
` --border-ui-active: color-mix(in srgb, ${ring} 70%, ${border});`,
` --input-border: ${input};`,
` --input-bg: ${card};`,
` --input-placeholder: color-mix(in srgb, ${mutedForeground} 72%, transparent);`,
` --input-focus-border: ${ring};`,
` --input-focus-ring: color-mix(in srgb, ${ring} 18%, transparent);`,
` --label-fg: color-mix(in srgb, ${foreground} 82%, transparent);`,
` --hover-bg: color-mix(in srgb, ${secondary} 72%, transparent);`,
` --active-bg: color-mix(in srgb, ${primary} 12%, transparent);`,
` --overlay-bg: color-mix(in srgb, ${background} 76%, transparent);`,
` --status-success-bg: color-mix(in srgb, ${success} 14%, transparent);`,
` --status-success-fg: ${success};`,
` --status-warning-bg: color-mix(in srgb, ${warning} 14%, transparent);`,
` --status-warning-fg: ${warning};`,
` --status-error-bg: color-mix(in srgb, ${destructive} 14%, transparent);`,
` --status-error-fg: ${destructive};`,
` --diff-added: ${success};`,
` --diff-added-strong: ${success};`,
` --diff-removed: ${destructive};`,
` --diff-removed-strong: ${destructive};`,
` --diff-diagonal: color-mix(in srgb, ${primary} 12%, transparent);`,
];
if (radius) {
lines.push(` --r-lg: ${radius};`);
lines.push(` --r-md: ${radius};`);
lines.push(` --r-sm: ${radius};`);
}
if (fontSans) {
lines.push(` --font-display: ${fontSans};`);
lines.push(` --font-body: ${fontSans};`);
}
if (fontMono) {
lines.push(` --font-mono: ${fontMono};`);
}
return lines;
}
function buildCustomThemeCSS(theme: CustomThemeData): string {
return [
":root.theme-custom {",
...buildModeLines(theme.light),
"}",
"",
":root.theme-custom.dark {",
...buildModeLines(theme.dark),
"}",
].join("\n");
}
export function applyCustomTheme(theme: CustomThemeData): void {
if (!hasDom()) return;
let styleEl = document.getElementById(CUSTOM_THEME_STYLE_ID) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = CUSTOM_THEME_STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = buildCustomThemeCSS(theme);
document.documentElement.classList.add("theme-custom");
loadGoogleFonts(extractGoogleFonts(theme));
}
export function removeCustomTheme(): void {
if (!hasDom()) return;
document.getElementById(CUSTOM_THEME_STYLE_ID)?.remove();
document.documentElement.classList.remove("theme-custom");
unloadGoogleFonts();
}
export function getStoredCustomTheme(): CustomThemeData | null {
if (typeof localStorage === "undefined") return null;
try {
const raw = localStorage.getItem(CUSTOM_THEME_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as CustomThemeData;
if (parsed && typeof parsed.light === "object" && typeof parsed.dark === "object") {
return parsed;
}
} catch {
// ignore bad storage payloads
}
return null;
}
export function setStoredCustomTheme(theme: CustomThemeData): void {
if (typeof localStorage === "undefined") return;
localStorage.setItem(CUSTOM_THEME_STORAGE_KEY, JSON.stringify(theme));
}
export function clearStoredCustomTheme(): void {
if (typeof localStorage === "undefined") return;
localStorage.removeItem(CUSTOM_THEME_STORAGE_KEY);
}
export function syncCustomThemeFromStorage(): void {
const theme = getStoredCustomTheme();
if (theme) {
applyCustomTheme(theme);
return;
}
removeCustomTheme();
}
+21 -5
View File
@@ -23,8 +23,6 @@ export interface NavItem {
soulModeOnly: boolean;
/** Link hidden when siteMode === "souls" */
soulModeHide: boolean;
/** Additional path prefixes that should also highlight this nav item (e.g. /skill for /skills) */
activePathPrefixes?: string[];
}
// ---------------------------------------------------------------------------
@@ -70,7 +68,6 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
activePathPrefixes: ["/skill/"],
},
{
label: "Plugins",
@@ -80,7 +77,6 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
activePathPrefixes: ["/plugin/"],
},
{
label: "Souls",
@@ -92,7 +88,6 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
soulModeOnly: false,
// In soul-mode this is the primary tab; in skills-mode it is also shown.
soulModeHide: false,
activePathPrefixes: ["/soul/"],
},
];
@@ -166,6 +161,27 @@ export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
{ kind: "link", label: "Skills", to: "/skills", search: SKILLS_SEARCH },
{ kind: "link", label: "Plugins", to: "/plugins" },
{ kind: "link", label: "Souls", to: "/souls", search: SOULS_SEARCH },
{ kind: "link", label: "Users", to: "/users" },
{
kind: "link",
label: "Staff Picks",
to: "/skills",
search: {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: true,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
},
},
{
kind: "link",
label: "Search",
to: "/search",
search: { q: undefined, type: undefined },
},
],
},
{
-39
View File
@@ -162,45 +162,6 @@ describe("fetchPackages", () => {
});
});
it("preserves package stats from package detail responses", async () => {
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
package: {
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
createdAt: 1,
updatedAt: 2,
tags: {},
stats: {
downloads: 7,
installs: 3,
stars: 2,
versions: 4,
},
},
owner: null,
}),
{ status: 200 },
),
);
await expect(fetchPackageDetail("demo-plugin")).resolves.toMatchObject({
package: {
stats: {
downloads: 7,
installs: 3,
stars: 2,
versions: 4,
},
},
});
});
it("forwards request cookies and includes credentials for package detail fetches", async () => {
vi.stubEnv("VITE_CONVEX_SITE_URL", "https://app.example");
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
+84 -81
View File
@@ -1,5 +1,4 @@
import type {
ApiV1PackageResponse,
PackageCapabilitySummary,
PackageCompatibility,
PackageVerificationSummary,
@@ -25,7 +24,30 @@ export type PackageListItem = {
verificationTier?: string | null;
};
export type PackageDetailResponse = ApiV1PackageResponse;
export type PackageDetailResponse = {
package: {
_id?: string;
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
runtimeId?: string | null;
channel: "official" | "community" | "private";
isOfficial: boolean;
summary?: string | null;
latestVersion?: string | null;
createdAt: number;
updatedAt: number;
tags: Record<string, string>;
compatibility?: PackageCompatibility | null;
capabilities?: PackageCapabilitySummary | null;
verification?: PackageVerificationSummary | null;
} | null;
owner: {
handle?: string | null;
displayName?: string | null;
image?: string | null;
} | null;
};
export type PackageVersionDetail = {
package: {
@@ -277,51 +299,32 @@ export async function fetchPluginCatalog(params: {
executesCode?: boolean;
limit?: number;
}): Promise<PluginCatalogResult> {
try {
if (params.family) {
const response = await fetchPackages({
q: params.q,
cursor: params.cursor,
family: params.family,
isOfficial: params.isOfficial,
executesCode: params.executesCode,
limit: params.limit,
});
if (hasOwnProperty(response, "results") && Array.isArray(response.results)) {
return {
items: response.results.map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
nextCursor: null,
};
}
const browseResponse = response as PackageCatalogBrowseResponse;
if (params.family) {
const response = await fetchPackages({
q: params.q,
cursor: params.cursor,
family: params.family,
isOfficial: params.isOfficial,
executesCode: params.executesCode,
limit: params.limit,
});
if (hasOwnProperty(response, "results") && Array.isArray(response.results)) {
return {
items: browseResponse?.items ?? [],
nextCursor: browseResponse?.nextCursor ?? null,
};
}
if (params.q?.trim()) {
const url = await packageApiUrl(`${ApiRoutes.plugins}/search`);
url.searchParams.set("q", params.q.trim());
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
}
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
const response = await fetchJson<{
results?: Array<{ score: number; package: PackageListItem }>;
}>(url);
return {
items: (response?.results ?? []).map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
items: response.results.map((entry) => entry.package),
nextCursor: null,
};
}
const url = await packageApiUrl(ApiRoutes.plugins);
if (params.cursor) url.searchParams.set("cursor", params.cursor);
const browseResponse = response as PackageCatalogBrowseResponse;
return {
items: browseResponse.items,
nextCursor: browseResponse.nextCursor,
};
}
if (params.q?.trim()) {
const url = await packageApiUrl(`${ApiRoutes.plugins}/search`);
url.searchParams.set("q", params.q.trim());
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
@@ -329,53 +332,53 @@ export async function fetchPluginCatalog(params: {
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
const result = await fetchJson<PluginCatalogResult>(url);
const response = await fetchJson<{
results: Array<{ score: number; package: PackageListItem }>;
}>(url);
return {
items: result?.items ?? [],
nextCursor: result?.nextCursor ?? null,
items: response.results.map((entry) => entry.package),
nextCursor: null,
};
} catch {
// Return empty result on API error to prevent SSR crashes
return { items: [], nextCursor: null };
}
const url = await packageApiUrl(ApiRoutes.plugins);
if (params.cursor) url.searchParams.set("cursor", params.cursor);
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
}
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
return await fetchJson<PluginCatalogResult>(url);
}
export async function fetchPackageDetail(name: string): Promise<PackageDetailResponse> {
try {
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}`);
const response = await packageFetch(url, "application/json");
if (response.status === 404 || !response.ok) {
return { package: null, owner: null };
}
return (await response.json()) as PackageDetailResponse;
} catch {
// Return empty result on API error to prevent SSR crashes
return { package: null, owner: null };
export async function fetchPackageDetail(name: string) {
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}`);
const response = await packageFetch(url, "application/json");
if (response.status === 404) {
return {
package: null,
owner: null,
} satisfies PackageDetailResponse;
}
if (!response.ok) throw await createPackageApiError(response);
return (await response.json()) as PackageDetailResponse;
}
export async function fetchPackageVersion(name: string, version: string): Promise<PackageVersionDetail | null> {
try {
const url = await packageApiUrl(
`${ApiRoutes.packages}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`,
);
return await fetchJson<PackageVersionDetail>(url);
} catch {
// Return null on API error to prevent SSR crashes
return null;
}
export async function fetchPackageVersion(name: string, version: string) {
const url = await packageApiUrl(
`${ApiRoutes.packages}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`,
);
return await fetchJson<PackageVersionDetail>(url);
}
export async function fetchPackageReadme(name: string, version?: string | null): Promise<string | null> {
try {
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
url.searchParams.set("path", "README.md");
if (version) url.searchParams.set("version", version);
const response = await packageFetch(url, "text/plain");
if (response.ok) return await response.text();
return null;
} catch {
// Return null on API error to prevent SSR crashes
return null;
}
export async function fetchPackageReadme(name: string, version?: string | null) {
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
url.searchParams.set("path", "README.md");
if (version) url.searchParams.set("version", version);
const response = await packageFetch(url, "text/plain");
if (response.ok) return await response.text();
if (response.status === 403 || response.status === 423 || response.status === 404) return null;
throw await createPackageApiError(response);
}
-19
View File
@@ -105,25 +105,6 @@ describe("buildPackageUploadEntries", () => {
expect(uploaded.map((entry) => entry.path)).toEqual(["package.json", "dist/index.js"]);
});
it("normalizes misleading text MIME types in upload entries", async () => {
const uploaded = await buildPackageUploadEntries(
[
{
name: "src/index.ts",
size: 20,
type: "video/mp2t",
},
],
{
generateUploadUrl: async () => "upload-1",
hashFile: async () => "sha:1",
uploadFile: async () => "storage:1",
},
);
expect(uploaded[0]?.contentType).toBe("application/typescript");
});
it("keeps nested archive paths when files do not have webkitRelativePath", async () => {
const uploaded = await buildPackageUploadEntries(
[
+1 -2
View File
@@ -1,5 +1,4 @@
import ignore from "ignore";
import { normalizeTextContentType } from "clawhub-schema/textFiles";
type NormalizePackageUploadPathOptions = {
stripTopLevelFolder?: boolean;
@@ -140,7 +139,7 @@ export async function buildPackageUploadEntries<TFile extends UploadablePackageF
size: file.size,
storageId,
sha256,
contentType: normalizeTextContentType(path, file.type) ?? file.type ?? undefined,
contentType: file.type || undefined,
});
}
-165
View File
@@ -1,165 +0,0 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
const PREFERENCES_KEY = "clawhub-preferences";
export type LayoutDensity = "comfortable" | "compact";
export type ListViewMode = "grid" | "list";
export type SidebarPosition = "left" | "right";
export type CodeFontSize = "small" | "medium" | "large";
export type AnimationLevel = "full" | "reduced" | "none";
export interface UserPreferences {
// Display preferences
layoutDensity: LayoutDensity;
listViewMode: ListViewMode;
showDescriptions: boolean;
showStats: boolean;
showTags: boolean;
// Advanced layout options
advancedMode: boolean;
sidebarPosition: SidebarPosition;
stickyHeader: boolean;
// Code & content preferences
codeFontSize: CodeFontSize;
lineNumbers: boolean;
wordWrap: boolean;
// Accessibility & motion
animationLevel: AnimationLevel;
reducedMotion: boolean;
highContrast: boolean;
// Notification preferences
emailNotifications: boolean;
browserNotifications: boolean;
// Experimental features
experimentalFeatures: boolean;
}
const defaultPreferences: UserPreferences = {
layoutDensity: "comfortable",
listViewMode: "grid",
showDescriptions: true,
showStats: true,
showTags: true,
advancedMode: false,
sidebarPosition: "right",
stickyHeader: true,
codeFontSize: "medium",
lineNumbers: true,
wordWrap: true,
animationLevel: "full",
reducedMotion: false,
highContrast: false,
emailNotifications: true,
browserNotifications: false,
experimentalFeatures: false,
};
// Simple event emitter for cross-tab sync
const listeners = new Set<() => void>();
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
function notifyListeners() {
listeners.forEach((listener) => listener());
}
function getStoredPreferences(): UserPreferences {
if (typeof window === "undefined") return defaultPreferences;
try {
const stored = window.localStorage.getItem(PREFERENCES_KEY);
if (!stored) return defaultPreferences;
const parsed = JSON.parse(stored) as Partial<UserPreferences>;
return { ...defaultPreferences, ...parsed };
} catch {
return defaultPreferences;
}
}
function savePreferences(prefs: UserPreferences) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(PREFERENCES_KEY, JSON.stringify(prefs));
notifyListeners();
} catch {
// Storage might be full or disabled
}
}
// Server snapshot for SSR
function getServerSnapshot(): UserPreferences {
return defaultPreferences;
}
export function usePreferences() {
const preferences = useSyncExternalStore(
subscribe,
getStoredPreferences,
getServerSnapshot
);
const updatePreference = useCallback(<K extends keyof UserPreferences>(
key: K,
value: UserPreferences[K]
) => {
const current = getStoredPreferences();
const updated = { ...current, [key]: value };
savePreferences(updated);
}, []);
const updatePreferences = useCallback((updates: Partial<UserPreferences>) => {
const current = getStoredPreferences();
const updated = { ...current, ...updates };
savePreferences(updated);
}, []);
const resetPreferences = useCallback(() => {
savePreferences(defaultPreferences);
}, []);
// Apply preferences as CSS variables/classes
useEffect(() => {
if (typeof document === "undefined") return;
const root = document.documentElement;
// Layout density
root.dataset.density = preferences.layoutDensity;
// Animation level
root.dataset.animation = preferences.animationLevel;
// High contrast mode
root.classList.toggle("high-contrast", preferences.highContrast);
// Reduced motion
root.classList.toggle("reduce-motion", preferences.reducedMotion || preferences.animationLevel === "none");
// Code font size
root.style.setProperty("--code-font-size",
preferences.codeFontSize === "small" ? "12px" :
preferences.codeFontSize === "large" ? "16px" : "14px"
);
}, [preferences]);
return {
preferences,
updatePreference,
updatePreferences,
resetPreferences,
isAdvancedMode: preferences.advancedMode,
};
}
export { defaultPreferences };
+31 -43
View File
@@ -1,22 +1,18 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { applyTheme, getStoredTheme, getStoredThemeName, getStoredThemeSelection, useThemeMode } from "./theme";
import { applyTheme, getStoredTheme, useThemeMode } from "./theme";
describe("theme", () => {
let store: Record<string, string>;
function Harness() {
const { family, mode, setFamily, setMode } = useThemeMode();
const { mode, setMode } = useThemeMode();
return (
<div>
<div data-testid="mode">{mode}</div>
<div data-testid="family">{family}</div>
<button type="button" onClick={() => setMode("dark")}>
dark
</button>
<button type="button" onClick={() => setFamily("hub")}>
hub
</button>
</div>
);
}
@@ -43,43 +39,27 @@ describe("theme", () => {
afterEach(() => {
document.documentElement.classList.remove("dark");
delete document.documentElement.dataset.theme;
delete document.documentElement.dataset.themeResolved;
delete document.documentElement.dataset.themeFamily;
delete document.documentElement.dataset.themeMode;
window.localStorage.clear();
vi.unstubAllGlobals();
});
it("reads stored selection with legacy fallback", () => {
it("reads stored theme with fallback", () => {
expect(getStoredTheme()).toBe("system");
expect(getStoredThemeName()).toBe("claw");
window.localStorage.setItem(
"clawhub-theme-selection",
JSON.stringify({ theme: "hub", mode: "light" }),
);
expect(getStoredThemeSelection()).toEqual({ theme: "hub", mode: "light" });
window.localStorage.clear();
window.localStorage.setItem("clawhub-theme", "dark");
expect(getStoredTheme()).toBe("dark");
window.localStorage.clear();
window.localStorage.setItem("clawdhub-theme", "openknot");
expect(getStoredThemeSelection()).toEqual({ theme: "claw", mode: "dark" });
window.localStorage.setItem("clawhub-theme", "nope");
expect(getStoredTheme()).toBe("system");
window.localStorage.setItem("clawdhub-theme", "dark");
expect(getStoredTheme()).toBe("dark");
});
it("applies family and resolved mode to the document", () => {
applyTheme("dark", "hub");
it("applies theme and toggles dark class", () => {
applyTheme("dark");
expect(document.documentElement.dataset.theme).toBe("dark");
expect(document.documentElement.dataset.themeResolved).toBe("dark");
expect(document.documentElement.dataset.themeFamily).toBe("hub");
expect(document.documentElement.classList.contains("dark")).toBe(true);
applyTheme("light", "claw");
applyTheme("light");
expect(document.documentElement.dataset.theme).toBe("light");
expect(document.documentElement.dataset.themeResolved).toBe("light");
expect(document.documentElement.dataset.themeFamily).toBe("claw");
expect(document.documentElement.classList.contains("dark")).toBe(false);
});
@@ -89,11 +69,27 @@ describe("theme", () => {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));
applyTheme("system", "claw");
expect(document.documentElement.dataset.themeResolved).toBe("dark");
applyTheme("system");
expect(document.documentElement.dataset.theme).toBe("dark");
});
it("useThemeMode persists family and mode", async () => {
it("useThemeMode persists and applies mode", async () => {
vi.stubGlobal("matchMedia", () => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));
render(<Harness />);
expect(screen.getByTestId("mode").textContent).toBe("system");
fireEvent.click(screen.getByRole("button", { name: "dark" }));
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe("dark");
});
expect(window.localStorage.getItem("clawhub-theme")).toBe("dark");
});
it("loads stored theme after mount without a mismatched initial render", async () => {
window.localStorage.setItem("clawhub-theme", "dark");
vi.stubGlobal("matchMedia", () => ({
matches: false,
addEventListener: vi.fn(),
@@ -101,18 +97,10 @@ describe("theme", () => {
}));
render(<Harness />);
expect(screen.getByTestId("mode").textContent).toBe("system");
expect(screen.getByTestId("family").textContent).toBe("claw");
fireEvent.click(screen.getByRole("button", { name: "hub" }));
fireEvent.click(screen.getByRole("button", { name: "dark" }));
await waitFor(() => {
expect(document.documentElement.dataset.themeFamily).toBe("hub");
expect(document.documentElement.dataset.themeResolved).toBe("dark");
expect(screen.getByTestId("mode").textContent).toBe("dark");
});
expect(window.localStorage.getItem("clawhub-theme")).toBe("dark");
expect(window.localStorage.getItem("clawhub-theme-name")).toBe("hub");
expect(document.documentElement.dataset.theme).toBe("dark");
});
});
+30 -205
View File
@@ -1,228 +1,53 @@
import { useEffect, useState } from 'react';
import {
clearStoredCustomTheme,
getStoredCustomTheme,
parseThemeInput,
setStoredCustomTheme,
syncCustomThemeFromStorage,
type CustomThemeData,
} from './customTheme';
import { useEffect, useState } from "react";
export type ThemeName = 'claw' | 'hub';
export type ThemeMode = 'system' | 'light' | 'dark';
export type ResolvedTheme = 'light' | 'dark';
export type ThemeMode = "system" | "light" | "dark";
export type ThemeSelection = {
theme: ThemeName;
mode: ThemeMode;
};
const THEME_SELECTION_KEY = 'clawhub-theme-selection';
const THEME_KEY = 'clawhub-theme';
const LEGACY_THEME_KEY = 'clawdhub-theme';
const THEME_NAME_KEY = 'clawhub-theme-name';
const THEME_CHANGE_EVENT = 'clawhub:themechange';
export const THEME_OPTIONS: Array<{ value: ThemeName; label: string; description: string }> = [
{
value: 'claw',
label: 'Claw',
description: 'OpenClaw black, white, and red.',
},
{
value: 'hub',
label: 'Hub',
description: 'Marketplace monochrome index with terminal-style contrast.',
},
];
export const THEME_FAMILY_OPTIONS = THEME_OPTIONS;
const VALID_THEME_NAMES = new Set<ThemeName>(['claw', 'hub']);
const VALID_THEME_MODES = new Set<ThemeMode>(['system', 'light', 'dark']);
const LEGACY_MAP: Record<string, ThemeSelection> = {
dark: { theme: 'claw', mode: 'dark' },
light: { theme: 'claw', mode: 'light' },
system: { theme: 'claw', mode: 'system' },
defaultTheme: { theme: 'claw', mode: 'dark' },
docsTheme: { theme: 'claw', mode: 'light' },
lightTheme: { theme: 'claw', mode: 'dark' },
landingTheme: { theme: 'claw', mode: 'dark' },
newTheme: { theme: 'claw', mode: 'dark' },
openknot: { theme: 'claw', mode: 'dark' },
fieldmanual: { theme: 'hub', mode: 'dark' },
clawdash: { theme: 'hub', mode: 'light' },
};
function parseThemeSelection(themeRaw: unknown, modeRaw: unknown): ThemeSelection {
const theme = typeof themeRaw === 'string' ? themeRaw : '';
const mode = typeof modeRaw === 'string' ? modeRaw : '';
const normalizedTheme = VALID_THEME_NAMES.has(theme as ThemeName)
? (theme as ThemeName)
: (LEGACY_MAP[theme]?.theme ?? 'claw');
const normalizedMode = VALID_THEME_MODES.has(mode as ThemeMode)
? (mode as ThemeMode)
: (LEGACY_MAP[theme]?.mode ?? 'system');
return { theme: normalizedTheme, mode: normalizedMode };
}
function persistThemeSelection(selection: ThemeSelection) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(THEME_SELECTION_KEY, JSON.stringify(selection));
window.localStorage.setItem(THEME_KEY, selection.mode);
window.localStorage.setItem(THEME_NAME_KEY, selection.theme);
}
export function getStoredThemeSelection(): ThemeSelection {
if (typeof window === 'undefined') return { theme: 'claw', mode: 'system' };
try {
const storedSelection = window.localStorage.getItem(THEME_SELECTION_KEY);
if (storedSelection) {
const parsed = JSON.parse(storedSelection) as Partial<ThemeSelection>;
return parseThemeSelection(parsed.theme, parsed.mode);
}
} catch {
// fall through to legacy keys
}
const storedMode = window.localStorage.getItem(THEME_KEY);
const storedTheme = window.localStorage.getItem(THEME_NAME_KEY);
if (storedMode || storedTheme) {
return parseThemeSelection(storedTheme, storedMode);
}
const legacy = window.localStorage.getItem(LEGACY_THEME_KEY);
if (legacy) {
return parseThemeSelection(legacy, undefined);
}
return { theme: 'claw', mode: 'system' };
}
const THEME_KEY = "clawhub-theme";
const LEGACY_THEME_KEY = "clawdhub-theme";
export function getStoredTheme(): ThemeMode {
return getStoredThemeSelection().mode;
if (typeof window === "undefined") return "system";
const stored = window.localStorage.getItem(THEME_KEY);
if (stored === "light" || stored === "dark" || stored === "system") return stored;
const legacy = window.localStorage.getItem(LEGACY_THEME_KEY);
if (legacy === "light" || legacy === "dark" || legacy === "system") return legacy;
return "system";
}
export function getStoredThemeName(): ThemeName {
return getStoredThemeSelection().theme;
function resolveTheme(mode: ThemeMode) {
if (mode !== "system") return mode;
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function getThemeFamilyLabel(theme: ThemeName): string {
return THEME_OPTIONS.find((option) => option.value === theme)?.label ?? 'Claw';
}
function resolveMode(mode: ThemeMode): ResolvedTheme {
if (mode !== 'system') return mode;
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function resolveTheme(theme: ThemeName, mode: ThemeMode): ResolvedTheme {
void theme;
return resolveMode(mode);
}
export function isDarkResolvedTheme(resolvedTheme: string | null | undefined): boolean {
return resolvedTheme === 'dark';
}
export function isDarkThemeResolved(): boolean {
if (typeof document === 'undefined') return false;
return isDarkResolvedTheme(document.documentElement.dataset.themeResolved);
}
export function applyTheme(selectionOrMode: ThemeSelection | ThemeMode, theme: ThemeName = 'claw') {
const selection = typeof selectionOrMode === 'string' ? { theme, mode: selectionOrMode } : selectionOrMode;
applyThemeSelection(selection);
}
export function applyThemeSelection(selection: ThemeSelection) {
if (typeof document === 'undefined') return;
const resolved = resolveTheme(selection.theme, selection.mode);
export function applyTheme(mode: ThemeMode) {
if (typeof document === "undefined") return;
const resolved = resolveTheme(mode);
document.documentElement.dataset.theme = resolved;
document.documentElement.dataset.themeResolved = resolved;
document.documentElement.dataset.themeMode = selection.mode;
document.documentElement.dataset.themeFamily = selection.theme;
document.documentElement.classList.toggle('dark', isDarkResolvedTheme(resolved));
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT));
}
}
export function onThemeChange(handler: () => void) {
if (typeof window === 'undefined') return () => {};
window.addEventListener(THEME_CHANGE_EVENT, handler);
return () => window.removeEventListener(THEME_CHANGE_EVENT, handler);
document.documentElement.classList.toggle("dark", resolved === "dark");
}
export function useThemeMode() {
const [selection, setSelection] = useState<ThemeSelection>({ theme: 'claw', mode: 'system' });
const [mode, setMode] = useState<ThemeMode>("system");
const [isHydrated, setIsHydrated] = useState(false);
const [customTheme, setCustomTheme] = useState<CustomThemeData | null>(null);
useEffect(() => {
setSelection(getStoredThemeSelection());
setCustomTheme(getStoredCustomTheme());
setMode(getStoredTheme());
setIsHydrated(true);
}, []);
useEffect(() => {
if (!isHydrated) return;
applyThemeSelection(selection);
persistThemeSelection(selection);
syncCustomThemeFromStorage();
if (selection.mode !== 'system' || typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return;
applyTheme(mode);
if (typeof window !== "undefined") {
window.localStorage.setItem(THEME_KEY, mode);
}
if (mode !== "system" || typeof window === "undefined") return;
const media = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => applyTheme(mode);
media.addEventListener("change", handler);
return () => media.removeEventListener("change", handler);
}, [isHydrated, mode]);
const media = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => {
applyThemeSelection(selection);
syncCustomThemeFromStorage();
};
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', handler);
return () => media.removeEventListener('change', handler);
}
media.addListener(handler);
return () => media.removeListener(handler);
}, [isHydrated, selection]);
const importCustomTheme = async (input: string) => {
const parsed = await parseThemeInput(input);
const theme = {
...parsed,
source: input.trim(),
};
setStoredCustomTheme(theme);
setCustomTheme(theme);
syncCustomThemeFromStorage();
return theme;
};
const clearCustomTheme = () => {
clearStoredCustomTheme();
setCustomTheme(null);
syncCustomThemeFromStorage();
};
return {
theme: selection.theme,
family: selection.theme,
mode: selection.mode,
selection,
customTheme,
setTheme: (theme: ThemeName) => setSelection((current) => ({ ...current, theme })),
setFamily: (theme: ThemeName) => setSelection((current) => ({ ...current, theme })),
setMode: (mode: ThemeMode) => setSelection((current) => ({ ...current, mode })),
importCustomTheme,
clearCustomTheme,
};
return { mode, setMode };
}
-21
View File
@@ -56,27 +56,6 @@ describe("uploadUtils", () => {
const id = await uploadFile("https://example.com/upload", new File(["x"], "x.txt"));
expect(id).toBe("st_123");
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
headers: { "Content-Type": "text/plain" },
});
vi.unstubAllGlobals();
});
it("normalizes misleading upload MIME types for TypeScript files", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ storageId: "st_123" }),
});
vi.stubGlobal("fetch", fetchMock);
await uploadFile(
"https://example.com/upload",
new File(["x"], "src/index.ts", { type: "video/mp2t" }),
);
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
headers: { "Content-Type": "application/typescript" },
});
vi.unstubAllGlobals();
});
+2 -9
View File
@@ -1,17 +1,10 @@
import {
isTextContentType,
normalizeTextContentType,
TEXT_FILE_EXTENSION_SET,
} from "clawhub-schema/textFiles";
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from "clawhub-schema/textFiles";
import { getUserFacingConvexError } from "./convexError";
export async function uploadFile(uploadUrl: string, file: File) {
const path = file.webkitRelativePath || file.name;
const contentType =
normalizeTextContentType(path, file.type) ?? file.type ?? "application/octet-stream";
const response = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": contentType },
headers: { "Content-Type": file.type || "application/octet-stream" },
body: file,
});
if (!response.ok) {
+10 -15
View File
@@ -1,38 +1,33 @@
import { useSyncExternalStore } from "react";
// Tiny external store for auth errors raised during sign-in.
// Syncs across components in this tab.
let authError: string | null = null;
const listeners = new Set<() => void>();
function emitChange() {
for (const listener of listeners) listener();
for (const listener of listeners) listener();
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
listeners.add(listener);
return () => listeners.delete(listener);
}
export function getAuthErrorSnapshot() {
return authError;
return authError;
}
export function setAuthError(error: string | null) {
if (authError === error) return;
authError = error;
emitChange();
if (authError === error) return;
authError = error;
emitChange();
}
export function clearAuthError() {
setAuthError(null);
setAuthError(null);
}
export function useAuthError() {
const error = useSyncExternalStore(
subscribe,
getAuthErrorSnapshot,
getAuthErrorSnapshot,
);
return { error, clear: clearAuthError };
const error = useSyncExternalStore(subscribe, getAuthErrorSnapshot, getAuthErrorSnapshot);
return { error, clear: clearAuthError };
}
+79 -95
View File
@@ -84,104 +84,88 @@ export const Route = createFileRoute('/about')({
function AboutPage() {
return (
<main className="section about-page">
<div className="about-bento">
<section className="about-panel about-panel-hero">
<div className="about-hero-copy">
<div className="skill-card-tags mb-3">
<Badge>About</Badge>
<Badge variant="accent">Policy</Badge>
</div>
<h1 className="about-title">What ClawHub will not host</h1>
<p className="about-lead">
ClawHub is for useful agent tooling, not abuse workflows. If a skill is built to
evade defenses, scam people, invade privacy, or enable non-consensual behavior, it
does not belong here.
</p>
<div className="about-hero">
<div className="about-hero-copy">
<div className="skill-card-tags mb-3">
<Badge>About</Badge>
<Badge variant="accent">Policy</Badge>
</div>
</section>
<aside className="about-panel about-panel-callout">
<div className="about-callout">
<span className="about-callout-label">Moderation stance</span>
<p>
We judge end-to-end abuse patterns, not keyword theater. Useful tooling stays.
Predatory workflows get removed.
</p>
</div>
</aside>
<section className="about-panel about-panel-categories">
<div className="home-section-header">
<h2 className="home-section-title">Immediate rejection categories</h2>
</div>
<div className="about-grid">
{prohibitedCategories.map((category, index) => (
<article
key={category.title}
className={`about-rule-card${index % 3 === 0 ? ' about-rule-card-featured' : ''}`}
>
<h2>{category.title}</h2>
<p>{category.examples}</p>
</article>
))}
</div>
</section>
<section className="about-panel about-panel-patterns">
<div className="home-section-header">
<h2 className="home-section-title">Recent patterns we are explicitly not okay with</h2>
</div>
<div className="about-patterns">
{recentPatterns.map((pattern) => (
<div key={pattern} className="about-pattern">
{pattern}
</div>
))}
</div>
</section>
<section className="about-panel about-panel-enforcement">
<div>
<span className="about-callout-label">Enforcement</span>
<div className="management-sublist">
<div className="management-subitem">
We may hide, remove, or hard-delete violating skills.
</div>
<div className="management-subitem">
We may revoke tokens, soft-delete associated content, and ban repeat or severe
offenders.
</div>
<div className="management-subitem">
We do not guarantee warning-first enforcement for obvious abuse.
</div>
</div>
</div>
</section>
<section className="about-panel about-panel-actions">
<span className="about-callout-label">Next steps</span>
<p className="about-panel-copy">
If you are reviewing a borderline workflow, use the reviewer doc. If you are browsing,
stay in the public catalog.
<h1 className="about-title">What ClawHub will not host</h1>
<p className="about-lead">
ClawHub is for useful agent tooling, not abuse workflows. If a skill is built to evade
defenses, scam people, invade privacy, or enable non-consensual behavior, it does not
belong here.
</p>
<div className="skill-card-tags">
<Button asChild variant="primary">
<Link to="/skills">
Browse Skills
</Link>
</Button>
<Button asChild>
<a
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
target="_blank"
rel="noreferrer"
>
Reviewer Doc
</a>
</Button>
</div>
</section>
</div>
<div className="about-callout">
<span className="about-callout-label">Moderation stance</span>
<p>
We judge end-to-end abuse patterns, not keyword theater. Useful tooling stays.
Predatory workflows get removed.
</p>
</div>
</div>
<section className="about-section">
<div className="home-section-header">
<h2 className="home-section-title">Immediate rejection categories</h2>
</div>
<div className="about-grid">
{prohibitedCategories.map((category) => (
<article key={category.title} className="about-rule-card">
<h2>{category.title}</h2>
<p>{category.examples}</p>
</article>
))}
</div>
</section>
<section className="about-section">
<div className="home-section-header">
<h2 className="home-section-title">Recent patterns we are explicitly not okay with</h2>
</div>
<div className="about-patterns">
{recentPatterns.map((pattern) => (
<div key={pattern} className="about-pattern">
{pattern}
</div>
))}
</div>
</section>
<section className="about-enforcement">
<div>
<span className="about-callout-label">Enforcement</span>
<div className="management-sublist">
<div className="management-subitem">
We may hide, remove, or hard-delete violating skills.
</div>
<div className="management-subitem">
We may revoke tokens, soft-delete associated content, and ban repeat or severe
offenders.
</div>
<div className="management-subitem">
We do not guarantee warning-first enforcement for obvious abuse.
</div>
</div>
</div>
<div className="skill-card-tags">
<Button asChild variant="primary">
<Link to="/skills">
Browse Skills
</Link>
</Button>
<Button asChild>
<a
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
target="_blank"
rel="noreferrer"
>
Reviewer Doc
</a>
</Button>
</div>
</section>
</main>
);
}
-113
View File
@@ -1,113 +0,0 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const createTokenMock = vi.fn();
const clearAuthErrorMock = vi.fn();
let mockSearch: {
redirect_uri?: string;
label?: string;
label_b64?: string;
state?: string;
} = {};
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: { component: unknown }) => ({
...config,
useSearch: () => mockSearch,
}),
}));
vi.mock("convex/react", () => ({
useMutation: () => createTokenMock,
}));
vi.mock("../../../convex/_generated/api", () => ({
api: {
tokens: {
create: "tokens.create",
},
},
}));
vi.mock("../../lib/useAuthStatus", () => ({
useAuthStatus: () => ({
isAuthenticated: true,
isLoading: false,
me: { _id: "user_123" },
}),
}));
vi.mock("../../lib/useAuthError", () => ({
useAuthError: () => ({
error: null,
clear: clearAuthErrorMock,
}),
}));
vi.mock("../../lib/site", () => ({
getClawHubSiteUrl: () => "https://clawhub.ai",
normalizeClawHubSiteOrigin: () => "https://clawhub.ai",
}));
vi.mock("../../components/layout/Container", () => ({
Container: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock("../../components/SignInButton", () => ({
SignInButton: ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>,
}));
vi.mock("../../components/ui/card", () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CardContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CardHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CardTitle: ({ children }: { children: React.ReactNode }) => <h1>{children}</h1>,
}));
const { CliAuth } = await import("./auth");
describe("CliAuth", () => {
const assignSpy = vi.fn();
beforeEach(() => {
createTokenMock.mockReset();
clearAuthErrorMock.mockReset();
assignSpy.mockReset();
mockSearch = {
redirect_uri: "http://127.0.0.1:43110/callback",
state: "state_123",
label: "CLI token",
};
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders the fallback token and retry link before attempting redirect", async () => {
createTokenMock.mockResolvedValue({ token: "clh_test_token" });
render(<CliAuth navigate={assignSpy} />);
await waitFor(() => {
expect(createTokenMock).toHaveBeenCalledWith({ label: "CLI token" });
});
await waitFor(() => {
expect(assignSpy).toHaveBeenCalledWith(
"http://127.0.0.1:43110/callback#token=clh_test_token&registry=https%3A%2F%2Fclawhub.ai&state=state_123",
);
});
expect(screen.getByText(/copy this token and run/i)).toBeTruthy();
expect(screen.getByText("clh_test_token")).toBeTruthy();
expect(screen.getByText(/Redirecting to CLI/i)).toBeTruthy();
expect(screen.getByRole("link", { name: /Retry redirect to CLI/i }).getAttribute("href")).toBe(
"http://127.0.0.1:43110/callback#token=clh_test_token&registry=https%3A%2F%2Fclawhub.ai&state=state_123",
);
});
});
+6 -28
View File
@@ -1,7 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { useMutation } from "convex/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { api } from "../../../convex/_generated/api";
import { Container } from "../../components/layout/Container";
import { SignInButton } from "../../components/SignInButton";
@@ -14,11 +13,7 @@ export const Route = createFileRoute("/cli/auth")({
component: CliAuth,
});
type CliAuthProps = {
navigate?: (url: string) => void;
};
export function CliAuth({ navigate = (url: string) => window.location.assign(url) }: CliAuthProps = {}) {
function CliAuth() {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const { error: authError, clear: clearAuthError } = useAuthError();
const createToken = useMutation(api.tokens.create);
@@ -31,7 +26,6 @@ export function CliAuth({ navigate = (url: string) => window.location.assign(url
};
const [status, setStatus] = useState<string>("Preparing...");
const [token, setToken] = useState<string | null>(null);
const [callbackUrl, setCallbackUrl] = useState<string | null>(null);
const hasRun = useRef(false);
const redirectUri = search.redirect_uri ?? "";
@@ -57,21 +51,13 @@ export function CliAuth({ navigate = (url: string) => window.location.assign(url
const run = async () => {
setStatus("Creating token...");
const result = await createToken({ label });
setToken(result.token);
setStatus("Redirecting to CLI...");
const hash = new URLSearchParams();
hash.set("token", result.token);
hash.set("registry", registry);
hash.set("state", state);
const redirectUrl = `${redirectUri}#${hash.toString()}`;
// Render the fallback token before attempting navigation so it is
// always visible if the browser blocks or fails the http:// redirect
// (e.g. ERR_CONNECTION_REFUSED when the CLI server has already shut
// down, or Chrome's HTTPS-first mode interfering with localhost).
flushSync(() => {
setToken(result.token);
setCallbackUrl(redirectUrl);
setStatus("Redirecting to CLI…");
});
navigate(redirectUrl);
window.location.assign(`${redirectUri}#${hash.toString()}`);
};
void run().catch((error) => {
@@ -79,7 +65,7 @@ export function CliAuth({ navigate = (url: string) => window.location.assign(url
setStatus(message);
setToken(null);
});
}, [createToken, isAuthenticated, label, me, navigate, redirectUri, registry, safeRedirect, state]);
}, [createToken, isAuthenticated, label, me, redirectUri, registry, safeRedirect, state]);
if (!safeRedirect) {
return (
@@ -173,16 +159,8 @@ export function CliAuth({ navigate = (url: string) => window.location.assign(url
<p className="text-sm text-[color:var(--ink-soft)]">{status}</p>
{token ? (
<div className="text-sm text-[color:var(--ink-soft)] overflow-x-auto">
<div className="mb-2">
If the redirect did not complete, copy this token and run{" "}
<code>clawhub login --token &lt;token&gt;</code>:
</div>
<div className="mb-2">If redirect fails, copy this token:</div>
<code className="font-mono text-xs">{token}</code>
{callbackUrl ? (
<div className="mt-2">
<a href={callbackUrl}>Retry redirect to CLI</a>
</div>
) : null}
</div>
) : null}
</CardContent>
+83 -264
View File
@@ -1,20 +1,6 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useAction, useQuery } from "convex/react";
import {
ArrowRight,
Code2,
Download,
Flame,
Ghost,
Package,
Search,
Sparkles,
Star,
TrendingUp,
Users,
Zap,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { api } from "../../convex/_generated/api";
import { SkillCard } from "../components/SkillCard";
import { SkillListItem } from "../components/SkillListItem";
@@ -39,19 +25,6 @@ function Home() {
return mode === "souls" ? <OnlyCrabsHome /> : <SkillsHome />;
}
const popularSearches = ["AI Writing", "Screenshot", "Productivity", "Analytics", "Automation"];
const categories = [
{ name: "Productivity", icon: "⚡", count: 324, className: "productivity" },
{ name: "AI & ML", icon: "🧠", count: 218, className: "ai" },
{ name: "Developer Tools", icon: "💻", count: 456, className: "developer" },
{ name: "Design", icon: "🎨", count: 189, className: "design" },
{ name: "Analytics", icon: "📊", count: 142, className: "analytics" },
{ name: "Security", icon: "🔐", count: 98, className: "security" },
{ name: "Automation", icon: "⚙️", count: 276, className: "automation" },
{ name: "Media", icon: "🖼️", count: 167, className: "media" },
];
function SkillsHome() {
type SkillPageEntry = {
skill: PublicSkill;
@@ -64,8 +37,6 @@ function SkillsHome() {
const [trending, setTrending] = useState<SkillPageEntry[]>([]);
const [recent, setRecent] = useState<SkillPageEntry[]>([]);
const [skillCount, setSkillCount] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const navigate = Route.useNavigate();
useEffect(() => {
let cancelled = false;
@@ -73,13 +44,13 @@ function SkillsHome() {
Promise.all([
convexHttp.query(api.skills.listHighlightedPublic, { limit: 6 }),
convexHttp.query(api.skills.listPublicPageV4, {
numItems: 6,
numItems: 8,
sort: "downloads",
dir: "desc",
nonSuspiciousOnly: true,
}),
convexHttp.query(api.skills.listPublicPageV4, {
numItems: 6,
numItems: 8,
sort: "updated",
dir: "desc",
nonSuspiciousOnly: true,
@@ -88,7 +59,7 @@ function SkillsHome() {
])
.then(([h, t, r, c]) => {
if (cancelled) return;
setHighlighted((h as SkillPageEntry[]).slice(0, 6));
setHighlighted(h as SkillPageEntry[]);
setTrending((t as { page: SkillPageEntry[] }).page);
setRecent((r as { page: SkillPageEntry[] }).page);
setSkillCount(c as number);
@@ -100,87 +71,51 @@ function SkillsHome() {
};
}, []);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const q = searchQuery.trim();
if (!q) return;
void navigate({
to: "/search",
search: { q, type: undefined },
});
};
const highlightedGridStyle = {
"--staff-picks-cols-lg": String(Math.max(1, Math.min(highlighted.length, 6))),
"--staff-picks-cols-md": String(Math.max(1, Math.min(highlighted.length, 3))),
"--staff-picks-cols-sm": String(Math.max(1, Math.min(highlighted.length, 2))),
"--staff-picks-cols-xs": "1",
} as CSSProperties;
return (
<main>
{/* Hero Section */}
<section className="home-hero">
<div className="home-hero-inner">
<div className="home-hero-grid">
<div className="home-hero-copy">
{/* Badge */}
<div className="home-hero-kicker">
<Sparkles size={14} className="home-hero-kicker-icon" />
<span>
{skillCount != null
? `${formatCompactStat(skillCount)} curated tools`
: "Thousands of curated tools"}
</span>
</div>
{/* Headline */}
<h1 className="home-hero-title">
Discover tools that{" "}
<span className="home-hero-title-accent">power your work</span>
</h1>
{/* Subheadline */}
<div className="home-hero-kicker">Discovery hub</div>
<h1 className="home-hero-title">The collaborative hub for agent skills</h1>
<p className="home-hero-subtitle">
The modern marketplace for internet tools. Find, compare, and install the best
software to supercharge your productivity.
{skillCount != null
? `${formatCompactStat(skillCount)} public skill bundles, plugin packages, and builder profiles in one shared index. Browse fast, fork the good stuff, ship your own.`
: "Public skill bundles, plugin packages, and builder profiles in one shared index. Browse fast, fork the good stuff, ship your own."}
</p>
{/* Search */}
<form className="home-hero-search" onSubmit={handleSearch}>
<div className="home-hero-search-wrapper">
<Search size={20} className="home-hero-search-icon" />
<input
type="text"
className="home-hero-search-input"
placeholder="Search for tools, categories, or features..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<button type="submit" className="home-hero-search-btn">
<span>Search</span>
<ArrowRight size={16} />
</button>
</div>
</form>
{/* Popular searches */}
<div className="home-hero-popular">
<span className="home-hero-popular-label">Popular:</span>
{popularSearches.map((search) => (
<button
key={search}
type="button"
className="home-hero-popular-tag"
onClick={() => setSearchQuery(search)}
<div className="home-hero-actions">
<Button asChild variant="primary">
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
>
{search}
</button>
))}
Browse All Skills & Plugins
</Link>
</Button>
<Button asChild className="home-hero-publish-btn">
<Link
to="/publish-skill"
search={{ updateSlug: undefined }}
>
+ Publish Yours
</Link>
</Button>
</div>
<p className="home-hero-explainer">
Sharp filters. Clean listings. Discovery that feels more like a real index and less
like a sad spreadsheet.
</p>
</div>
{/* Discovery Panels */}
<div className="home-hero-panels" id="home-discovery">
<Link
to="/skills"
@@ -195,25 +130,19 @@ function SkillsHome() {
}}
className="home-hero-panel"
>
<div className="home-hero-panel-icon">
<Zap size={20} />
</div>
<strong>Skills</strong>
<span>Browse ranked skill bundles</span>
<span className="home-hero-panel-label">Skills</span>
<strong>Browse ranked skill bundles</strong>
<span>Popular installs, fresh updates, staff picks.</span>
</Link>
<Link to="/plugins" className="home-hero-panel">
<div className="home-hero-panel-icon">
<Code2 size={20} />
</div>
<strong>Plugins</strong>
<span>Agent-ready packages</span>
<span className="home-hero-panel-label">Plugins</span>
<strong>Find agent-ready packages</strong>
<span>Code plugins, bundles, and verified publishers.</span>
</Link>
<Link to="/users" search={{ q: undefined }} className="home-hero-panel">
<div className="home-hero-panel-icon">
<Users size={20} />
</div>
<strong>Builders</strong>
<span>Meet the creators</span>
<span className="home-hero-panel-label">Users</span>
<strong>Meet the builders</strong>
<span>Profiles, bios, and the people shipping useful stuff.</span>
</Link>
<Link
to="/souls"
@@ -226,51 +155,20 @@ function SkillsHome() {
}}
className="home-hero-panel"
>
<div className="home-hero-panel-icon">
<Ghost size={20} />
</div>
<strong>Souls</strong>
<span>SOUL.md discovery</span>
<span className="home-hero-panel-label">Souls</span>
<strong>SOUL.md discovery is coming</strong>
<span>Holding page for the next catalog surface.</span>
</Link>
</div>
</div>
</div>
</section>
{/* Stats Bar */}
<section className="home-section">
<div className="home-stats">
<div className="home-stat">
<div className="home-stat-value">
{skillCount != null ? formatCompactStat(skillCount) : "2.4K"}+
</div>
<div className="home-stat-label">Curated Tools</div>
</div>
<div className="home-stat">
<div className="home-stat-value">180K+</div>
<div className="home-stat-label">Active Users</div>
</div>
<div className="home-stat">
<div className="home-stat-value">12M+</div>
<div className="home-stat-label">Total Downloads</div>
</div>
<div className="home-stat">
<div className="home-stat-value">4.8</div>
<div className="home-stat-label">Avg. Rating</div>
</div>
</div>
</section>
{/* Trending */}
{trending.length > 0 ? (
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">
<span className="home-section-title-icon trending">
<TrendingUp size={16} />
</span>
Trending Now
</h2>
<h2 className="home-section-title">Trending</h2>
<Link
to="/skills"
search={{
@@ -284,8 +182,7 @@ function SkillsHome() {
}}
className="home-section-link"
>
View all
<ArrowRight size={14} />
See all
</Link>
</div>
<div className="results-list">
@@ -305,12 +202,7 @@ function SkillsHome() {
{recent.length > 0 ? (
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">
<span className="home-section-title-icon recent">
<Sparkles size={16} />
</span>
Recently Updated
</h2>
<h2 className="home-section-title">Recently updated</h2>
<Link
to="/skills"
search={{
@@ -324,8 +216,7 @@ function SkillsHome() {
}}
className="home-section-link"
>
View all
<ArrowRight size={14} />
See all
</Link>
</div>
<div className="results-list">
@@ -345,12 +236,7 @@ function SkillsHome() {
{highlighted.length > 0 ? (
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">
<span className="home-section-title-icon featured">
<Star size={16} />
</span>
Staff Picks
</h2>
<h2 className="home-section-title">Staff picks</h2>
<Link
to="/skills"
search={{
@@ -364,12 +250,12 @@ function SkillsHome() {
}}
className="home-section-link"
>
View all
<ArrowRight size={14} />
See all
</Link>
</div>
<div className="home-staff-picks-grid" style={highlightedGridStyle}>
{highlighted.map((entry) => (
<div className="grid">
{
highlighted.map((entry) => (
<SkillCard
key={entry.skill._id}
skill={entry.skill}
@@ -394,94 +280,41 @@ function SkillsHome() {
</section>
) : null}
{/* Categories */}
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">Browse by Category</h2>
</div>
<div className="home-categories">
{categories.map((category) => (
<Link
key={category.name}
to="/skills"
search={{
q: category.name,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
className="home-category-card"
>
<div className={`home-category-icon ${category.className}`}>{category.icon}</div>
<div className="home-category-content">
<div className="home-category-name">{category.name}</div>
<div className="home-category-count">{category.count} tools</div>
</div>
</Link>
))}
</div>
</section>
{/* Quick links */}
<section className="home-section">
<div className="home-quick-links">
<Link
to="/skills"
search={{
q: undefined,
sort: "stars" as const,
dir: "desc" as const,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
search={{ q: undefined, sort: "stars" as const, dir: "desc" as const, highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}
className="home-quick-link"
>
<Star size={14} className="home-quick-link-icon" />
Most starred
</Link>
<Link
to="/skills"
search={{
q: undefined,
sort: "newest" as const,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
search={{ q: undefined, sort: "newest" as const, dir: undefined, highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}
className="home-quick-link"
>
<Sparkles size={14} className="home-quick-link-icon" />
New this week
</Link>
<Link to="/plugins" className="home-quick-link">
<Code2 size={14} className="home-quick-link-icon" />
Browse plugins
</Link>
<Link to="/users" search={{ q: undefined }} className="home-quick-link">
<Users size={14} className="home-quick-link-icon" />
Browse users
</Link>
<Link
to="/souls"
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined, focus: undefined }}
className="home-quick-link"
>
Souls coming soon
</Link>
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: true,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
search={{ q: undefined, sort: undefined, dir: undefined, highlighted: true, nonSuspicious: undefined, view: undefined, focus: undefined }}
className="home-quick-link"
>
<Star size={14} className="home-quick-link-icon" />
Staff picks
</Link>
</div>
@@ -490,6 +323,7 @@ function SkillsHome() {
);
}
function OnlyCrabsHome() {
const navigate = Route.useNavigate();
const ensureSoulSeeds = useAction(api.seed.ensureSoulSeeds);
@@ -510,13 +344,8 @@ function OnlyCrabsHome() {
<div className="home-hero-inner">
<div className="home-hero-grid">
<div className="home-hero-copy">
<div className="home-hero-kicker">
<Ghost size={14} className="home-hero-kicker-icon" />
<span>OnlyCrabs</span>
</div>
<h1 className="home-hero-title">
<span className="home-hero-title-accent">SoulHub</span>, where system lore lives.
</h1>
<div className="home-hero-kicker">OnlyCrabs</div>
<h1 className="home-hero-title">SoulHub, where system lore lives.</h1>
<p className="home-hero-subtitle">
Share SOUL.md bundles, version them like docs, and keep personal system lore in one
public place.
@@ -537,20 +366,16 @@ function OnlyCrabsHome() {
});
}}
>
<div className="home-hero-search-wrapper">
<Search size={20} className="home-hero-search-icon" />
<input
className="home-hero-search-input"
type="text"
placeholder="Search souls, prompts, or lore"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<button type="submit" className="home-hero-search-btn">
<span>Search</span>
<ArrowRight size={16} />
</button>
</div>
<input
className="home-hero-search-input"
type="text"
placeholder="Search souls, prompts, or lore"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<Button variant="primary" type="submit">
Search
</Button>
</form>
</div>
</div>
@@ -559,12 +384,7 @@ function OnlyCrabsHome() {
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">
<span className="home-section-title-icon recent">
<Sparkles size={16} />
</span>
Latest Souls
</h2>
<h2 className="home-section-title">Latest souls</h2>
<Link
to="/souls"
search={{
@@ -576,8 +396,7 @@ function OnlyCrabsHome() {
}}
className="home-section-link"
>
View all
<ArrowRight size={14} />
See all
</Link>
</div>
<div className="grid">
+58 -30
View File
@@ -14,6 +14,7 @@ import {
fetchPackageReadme,
fetchPackageVersion,
getPackageDownloadPath,
isRateLimitedPackageApiError,
type PackageDetailResponse,
type PackageVersionDetail,
} from "../../lib/packageApi";
@@ -35,7 +36,6 @@ type PluginDetailLoaderData = {
export const Route = createFileRoute("/plugins/$name")({
loader: async ({ params }): Promise<PluginDetailLoaderData> => {
// All fetch functions now handle errors internally and return null/empty on failure
const requestedName = params.name;
const candidateNames = requestedName.includes("/")
? [requestedName]
@@ -43,9 +43,24 @@ export const Route = createFileRoute("/plugins/$name")({
let resolvedName = requestedName;
let detail: PackageDetailResponse = { package: null, owner: null };
for (const candidateName of candidateNames) {
const candidateDetail = await fetchPackageDetail(candidateName);
let candidateDetail: PackageDetailResponse;
try {
candidateDetail = await fetchPackageDetail(candidateName);
} catch (error) {
if (isRateLimitedPackageApiError(error)) {
return {
detail: { package: null, owner: null },
version: null,
readme: null,
rateLimited: {
scope: "detail",
retryAfterSeconds: error.retryAfterSeconds,
},
};
}
throw error;
}
if (candidateDetail.package) {
detail = candidateDetail;
resolvedName = candidateName;
@@ -55,18 +70,35 @@ export const Route = createFileRoute("/plugins/$name")({
}
if (!detail.package) {
return { detail, version: null, readme: null, rateLimited: null };
return {
detail,
version: null,
readme: null,
rateLimited: null,
};
}
// Fetch readme and version in parallel - functions handle errors internally
const [version, readme] = await Promise.all([
detail.package.latestVersion
? fetchPackageVersion(resolvedName, detail.package.latestVersion)
: Promise.resolve(null),
fetchPackageReadme(resolvedName),
]);
return { detail, version, readme, rateLimited: null };
let metadataRateLimited: PluginDetailRateLimitState = null;
const readmePromise = fetchPackageReadme(resolvedName).catch((error: unknown) => {
if (!isRateLimitedPackageApiError(error)) throw error;
metadataRateLimited ??= {
scope: "metadata",
retryAfterSeconds: error.retryAfterSeconds,
};
return null;
});
const versionPromise = detail.package?.latestVersion
? fetchPackageVersion(resolvedName, detail.package.latestVersion).catch((error: unknown) => {
if (!isRateLimitedPackageApiError(error)) throw error;
metadataRateLimited ??= {
scope: "metadata",
retryAfterSeconds: error.retryAfterSeconds,
};
return null;
})
: Promise.resolve(null);
const [version, readme] = await Promise.all([versionPromise, readmePromise]);
return { detail, version, readme, rateLimited: metadataRateLimited };
},
head: ({ params, loaderData }) => ({
meta: [
@@ -265,7 +297,7 @@ function PluginDetailRoute() {
return (
<main className="section">
<div className="flex min-w-0 flex-col gap-5">
<div className="flex flex-col gap-5">
{/* Header card */}
<Card>
<CardContent>
@@ -283,7 +315,7 @@ function PluginDetailRoute() {
</Badge>
) : null}
</div>
<h1 className="mb-1 break-words font-display text-2xl font-bold text-[color:var(--ink)]">
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)] mb-1">
{pkg.displayName}
{pkg.latestVersion ? (
<span className="ml-2 inline-block rounded-[var(--radius-pill)] bg-[color:var(--surface-muted)] px-2 py-0.5 text-xs font-semibold text-[color:var(--ink-soft)]">
@@ -291,16 +323,16 @@ function PluginDetailRoute() {
</span>
) : null}
</h1>
<p className="mb-2 break-words text-sm text-[color:var(--ink-soft)]">
<p className="text-sm text-[color:var(--ink-soft)] mb-2">
{pkg.summary ?? "No summary provided."}
</p>
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm text-[color:var(--ink-soft)]">
<span className="break-all font-mono text-xs">{pkg.name}</span>
<div className="flex flex-wrap items-center gap-2 text-sm text-[color:var(--ink-soft)]">
<span className="font-mono text-xs">{pkg.name}</span>
{pkg.runtimeId ? (
<>
<span className="opacity-40">&middot;</span>
<span>
runtime <span className="break-all font-mono text-xs">{pkg.runtimeId}</span>
runtime <span className="font-mono text-xs">{pkg.runtimeId}</span>
</span>
</>
) : null}
@@ -327,7 +359,7 @@ function PluginDetailRoute() {
{/* Install */}
<div className="mt-4">
<div className="flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3 sm:flex-row sm:items-center sm:gap-2">
<pre className="plugin-detail-code-block min-w-0 flex-1 font-mono text-xs text-[color:var(--ink)]">
<pre className="min-w-0 flex-1 overflow-x-auto font-mono text-xs text-[color:var(--ink)]">
<code>{installSnippet}</code>
</pre>
<CopyButton text={installSnippet} />
@@ -365,7 +397,7 @@ function PluginDetailRoute() {
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{CAPABILITY_LABELS[key] ?? key}
</dt>
<dd className="min-w-0 break-words text-[color:var(--ink)]">
<dd className="text-[color:var(--ink)]">
{key === "capabilityTags" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((tag) => (
@@ -407,9 +439,7 @@ function PluginDetailRoute() {
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{String(value)}
</dd>
<dd className="font-mono text-xs text-[color:var(--ink)]">{String(value)}</dd>
</div>
))}
</dl>
@@ -474,7 +504,7 @@ function PluginDetailRoute() {
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex max-w-full flex-wrap items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
className="inline-flex items-center gap-1 text-[color:var(--accent)] hover:underline"
>
{display}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
@@ -487,7 +517,7 @@ function PluginDetailRoute() {
{verification.sourceCommit ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
<dd className="font-mono text-xs text-[color:var(--ink)]">
{verification.sourceCommit.slice(0, 12)}
</dd>
</div>
@@ -495,7 +525,7 @@ function PluginDetailRoute() {
{verification.sourceTag ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
<dd className="font-mono text-xs text-[color:var(--ink)]">
{verification.sourceTag}
</dd>
</div>
@@ -530,9 +560,7 @@ function PluginDetailRoute() {
{Object.entries(pkg.tags).map(([key, value]) => (
<div key={key} className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{value}
</dd>
<dd className="font-mono text-xs text-[color:var(--ink)]">{value}</dd>
</div>
))}
</dl>
+41 -48
View File
@@ -6,6 +6,7 @@ import { PluginListItem } from "../../components/PluginListItem";
import { Button } from "../../components/ui/button";
import {
fetchPluginCatalog,
isRateLimitedPackageApiError,
type PackageListItem,
} from "../../lib/packageApi";
@@ -22,7 +23,6 @@ type PluginsLoaderData = {
nextCursor: string | null;
rateLimited: boolean;
retryAfterSeconds: number | null;
apiError?: boolean;
};
function formatRetryDelay(retryAfterSeconds: number | null) {
@@ -54,41 +54,42 @@ export const Route = createFileRoute("/plugins/")({
: undefined,
}),
loaderDeps: ({ search }) => search,
loader: async ({ deps }): Promise<PluginsLoaderData> => {
// fetchPluginCatalog now handles errors internally and returns empty results
const data = await fetchPluginCatalog({
q: deps.q,
cursor: deps.q ? undefined : deps.cursor,
family: deps.family,
isOfficial: deps.verified,
executesCode: deps.executesCode,
limit: 50,
});
const items = data?.items ?? [];
return {
items,
nextCursor: data?.nextCursor ?? null,
rateLimited: false,
retryAfterSeconds: null,
apiError: items.length === 0 && !deps.q && !deps.family && !deps.verified && !deps.executesCode,
};
loader: async ({ deps }) => {
try {
const data = await fetchPluginCatalog({
q: deps.q,
cursor: deps.q ? undefined : deps.cursor,
family: deps.family,
isOfficial: deps.verified,
executesCode: deps.executesCode,
limit: 50,
});
return {
items: data.items ?? [],
nextCursor: data.nextCursor ?? null,
rateLimited: false,
retryAfterSeconds: null,
} satisfies PluginsLoaderData;
} catch (error) {
if (isRateLimitedPackageApiError(error)) {
return {
items: [],
nextCursor: null,
rateLimited: true,
retryAfterSeconds: (error as { retryAfterSeconds?: number }).retryAfterSeconds ?? null,
} satisfies PluginsLoaderData;
}
throw error;
}
},
component: PluginsIndex,
});
function PluginsIndex() {
export function PluginsIndex() {
const search = Route.useSearch();
const navigate = Route.useNavigate();
const loaderData = Route.useLoaderData() as PluginsLoaderData | undefined;
// Defensive handling for when loader data is unavailable (SSR errors, etc.)
const items = loaderData?.items ?? [];
const nextCursor = loaderData?.nextCursor ?? null;
const rateLimited = loaderData?.rateLimited ?? false;
const retryAfterSeconds = loaderData?.retryAfterSeconds ?? null;
const apiError = loaderData?.apiError ?? !loaderData;
const { items, nextCursor, rateLimited, retryAfterSeconds } =
Route.useLoaderData() as PluginsLoaderData;
const [query, setQuery] = useState(search.q ?? "");
const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -142,16 +143,16 @@ function PluginsIndex() {
return (
<main className="browse-page">
<div className="browse-page-header">
<button
className="browse-sidebar-toggle"
type="button"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label="Toggle filters"
>
Filters
</button>
<h1 className="browse-title">Plugins</h1>
<div className="browse-page-actions">
<div className="flex items-center gap-2">
<button
className="browse-sidebar-toggle"
type="button"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label="Toggle filters"
>
Filters
</button>
<Button asChild variant="primary">
<Link
to="/publish-plugin"
@@ -200,15 +201,7 @@ function PluginsIndex() {
</span>
</div>
{apiError ? (
<div className="empty-state">
<AlertTriangle size={20} aria-hidden="true" />
<p className="empty-state-title">Unable to load plugins</p>
<p className="empty-state-body">
The plugin catalog is temporarily unavailable. Please try again later.
</p>
</div>
) : rateLimited ? (
{rateLimited ? (
<div className="empty-state">
<AlertTriangle size={20} aria-hidden="true" />
<p className="empty-state-title">Plugin catalog is temporarily unavailable</p>
+1 -2
View File
@@ -4,7 +4,6 @@ import {
PLATFORM_SKILL_LICENSE_NAME,
PLATFORM_SKILL_LICENSE_SUMMARY,
} from "clawhub-schema/licenseConstants";
import { normalizeTextContentType } from "clawhub-schema/textFiles";
import { useAction, useMutation, useQuery } from "convex/react";
import { Upload as UploadIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -424,7 +423,7 @@ export function Upload() {
size: file.size,
storageId,
sha256,
contentType: normalizeTextContentType(path, file.type) ?? file.type ?? undefined,
contentType: file.type || undefined,
});
}
+1 -436
View File
@@ -1,28 +1,15 @@
import { createFileRoute } from "@tanstack/react-router";
import { useMutation, useQuery } from "convex/react";
import {
Eye,
Grid3X3,
LayoutGrid,
List,
Monitor,
Moon,
RotateCcw,
Settings2,
Sparkles,
Sun,
Zap,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import { Container } from "../components/layout/Container";
import { SignInButton } from "../components/SignInButton";
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { SignInButton } from "../components/SignInButton";
import {
Dialog,
DialogContent,
@@ -34,25 +21,9 @@ import {
} from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../components/ui/select";
import { Separator } from "../components/ui/separator";
import { Switch } from "../components/ui/switch";
import { Textarea } from "../components/ui/textarea";
import { gravatarUrl } from "../lib/gravatar";
import {
type AnimationLevel,
type CodeFontSize,
type LayoutDensity,
type ListViewMode,
usePreferences,
} from "../lib/preferences";
import { getThemeFamilyLabel, THEME_FAMILY_OPTIONS, useThemeMode } from "../lib/theme";
export const Route = createFileRoute("/settings")({
component: Settings,
@@ -62,16 +33,6 @@ export function Settings() {
const me = useQuery(api.users.me);
const updateProfile = useMutation(api.users.updateProfile);
const deleteAccount = useMutation(api.users.deleteAccount);
const {
customTheme,
family: themeFamily,
importCustomTheme,
mode: themeMode,
setFamily: setThemeFamily,
setMode: setThemeMode,
clearCustomTheme,
} = useThemeMode();
const { preferences, updatePreference, resetPreferences, isAdvancedMode } = usePreferences();
const tokens = useQuery(api.tokens.listMine, me ? {} : "skip") as
| Array<{
_id: Id<"apiTokens">;
@@ -108,7 +69,6 @@ export function Settings() {
const [memberHandle, setMemberHandle] = useState("");
const [memberRole, setMemberRole] = useState<"owner" | "admin" | "publisher">("publisher");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [customThemeInput, setCustomThemeInput] = useState("");
const orgs = (publisherMemberships ?? []).filter((entry) => entry.publisher.kind === "org");
const selectedOrg =
orgs.find((entry) => entry.publisher.handle === selectedOrgHandle) ?? orgs[0] ?? null;
@@ -191,20 +151,6 @@ export function Settings() {
}
}
async function onImportCustomTheme() {
try {
const importedTheme = await importCustomTheme(customThemeInput);
setCustomThemeInput("");
toast.success(`Imported ${importedTheme.name ?? "custom theme"}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to import theme");
}
}
function onRemoveCustomTheme() {
clearCustomTheme();
toast.success("Removed custom theme");
}
return (
<Container size="narrow" className="py-10">
<main className="flex flex-col gap-6">
@@ -262,387 +208,6 @@ export function Settings() {
</form>
</Card>
{/* Customization */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Settings2 size={18} />
Customization
</CardTitle>
<CardDescription>
Personalize your ClawHub experience
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="advanced-mode" className="text-sm text-[color:var(--ink-soft)]">
Advanced
</Label>
<Switch
id="advanced-mode"
checked={isAdvancedMode}
onCheckedChange={(checked) => updatePreference("advancedMode", checked)}
/>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Theme Section */}
<div className="space-y-3">
<Label id="theme" className="text-sm font-semibold text-[color:var(--ink)]">Theme</Label>
<div className="grid gap-3 sm:grid-cols-3">
{THEME_FAMILY_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setThemeFamily(option.value)}
className="rounded-[var(--r-md)] border p-4 text-left transition-colors"
style={{
borderColor:
themeFamily === option.value ? "var(--accent)" : "var(--border-ui)",
background:
themeFamily === option.value
? "var(--accent-subtle)"
: "var(--surface-muted)",
}}
>
<div className="text-sm font-semibold text-[color:var(--ink)]">{option.label}</div>
<div className="mt-1 text-xs text-[color:var(--ink-soft)]">
{option.description}
</div>
</button>
))}
</div>
<div className="flex flex-wrap gap-2">
<Button
variant={themeMode === "light" ? "primary" : "ghost"}
size="sm"
onClick={() => setThemeMode("light")}
className="flex items-center gap-2"
>
<Sun size={14} />
Light
</Button>
<Button
variant={themeMode === "dark" ? "primary" : "ghost"}
size="sm"
onClick={() => setThemeMode("dark")}
className="flex items-center gap-2"
>
<Moon size={14} />
Dark
</Button>
<Button
variant={themeMode === "system" ? "primary" : "ghost"}
size="sm"
onClick={() => setThemeMode("system")}
className="flex items-center gap-2"
>
<Monitor size={14} />
System
</Button>
</div>
<div className="space-y-3 rounded-[var(--r-md)] border border-[color:var(--border-ui)] bg-[color:var(--surface-muted)] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[color:var(--ink)]">
tweakcn overlay
</div>
<p className="text-xs text-[color:var(--ink-soft)]">
Paste a tweakcn URL, a tweakcn theme name, CSS variables, or JSON.
</p>
</div>
{customTheme ? (
<Badge variant="accent">
{customTheme.name} on {getThemeFamilyLabel(themeFamily)}
</Badge>
) : null}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={customThemeInput}
onChange={(event) => setCustomThemeInput(event.target.value)}
placeholder="https://tweakcn.com/... or midnight-ocean"
aria-label="Import tweakcn theme"
/>
<Button type="button" variant="primary" onClick={onImportCustomTheme}>
Import
</Button>
{customTheme ? (
<Button type="button" variant="ghost" onClick={onRemoveCustomTheme}>
<RotateCcw size={14} />
Remove
</Button>
) : null}
</div>
{customTheme ? (
<div className="text-xs text-[color:var(--ink-soft)]">
Source: {customTheme.source}
</div>
) : null}
</div>
</div>
<Separator />
{/* Layout Section */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)]">Layout</Label>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="layout-density" className="text-xs text-[color:var(--ink-soft)]">
Density
</Label>
<Select
value={preferences.layoutDensity}
onValueChange={(value) => updatePreference("layoutDensity", value as LayoutDensity)}
>
<SelectTrigger id="layout-density">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="comfortable">
<span className="flex items-center gap-2">
<LayoutGrid size={14} />
Comfortable
</span>
</SelectItem>
<SelectItem value="compact">
<span className="flex items-center gap-2">
<Grid3X3 size={14} />
Compact
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="list-view" className="text-xs text-[color:var(--ink-soft)]">
Default view
</Label>
<Select
value={preferences.listViewMode}
onValueChange={(value) => updatePreference("listViewMode", value as ListViewMode)}
>
<SelectTrigger id="list-view">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="grid">
<span className="flex items-center gap-2">
<LayoutGrid size={14} />
Grid
</span>
</SelectItem>
<SelectItem value="list">
<span className="flex items-center gap-2">
<List size={14} />
List
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="show-descriptions" className="text-sm">Show descriptions</Label>
<Switch
id="show-descriptions"
checked={preferences.showDescriptions}
onCheckedChange={(checked) => updatePreference("showDescriptions", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="show-stats" className="text-sm">Show statistics</Label>
<Switch
id="show-stats"
checked={preferences.showStats}
onCheckedChange={(checked) => updatePreference("showStats", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="show-tags" className="text-sm">Show tags</Label>
<Switch
id="show-tags"
checked={preferences.showTags}
onCheckedChange={(checked) => updatePreference("showTags", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="sticky-header" className="text-sm">Sticky header</Label>
<Switch
id="sticky-header"
checked={preferences.stickyHeader}
onCheckedChange={(checked) => updatePreference("stickyHeader", checked)}
/>
</div>
</div>
</div>
{isAdvancedMode ? (
<>
<Separator />
{/* Code & Content Section - Advanced */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
<Sparkles size={14} className="text-[color:var(--accent)]" />
Code &amp; Content
</Label>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="code-font-size" className="text-xs text-[color:var(--ink-soft)]">
Code font size
</Label>
<Select
value={preferences.codeFontSize}
onValueChange={(value) => updatePreference("codeFontSize", value as CodeFontSize)}
>
<SelectTrigger id="code-font-size">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="small">Small (12px)</SelectItem>
<SelectItem value="medium">Medium (14px)</SelectItem>
<SelectItem value="large">Large (16px)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="animation-level" className="text-xs text-[color:var(--ink-soft)]">
Animation level
</Label>
<Select
value={preferences.animationLevel}
onValueChange={(value) => updatePreference("animationLevel", value as AnimationLevel)}
>
<SelectTrigger id="animation-level">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full">
<span className="flex items-center gap-2">
<Zap size={14} />
Full
</span>
</SelectItem>
<SelectItem value="reduced">Reduced</SelectItem>
<SelectItem value="none">None</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="line-numbers" className="text-sm">Line numbers in code</Label>
<Switch
id="line-numbers"
checked={preferences.lineNumbers}
onCheckedChange={(checked) => updatePreference("lineNumbers", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="word-wrap" className="text-sm">Word wrap in code</Label>
<Switch
id="word-wrap"
checked={preferences.wordWrap}
onCheckedChange={(checked) => updatePreference("wordWrap", checked)}
/>
</div>
</div>
</div>
<Separator />
{/* Accessibility Section - Advanced */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
<Eye size={14} className="text-[color:var(--accent)]" />
Accessibility
</Label>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="reduced-motion" className="text-sm">Reduced motion</Label>
<p className="text-xs text-[color:var(--ink-soft)]">Minimize animations</p>
</div>
<Switch
id="reduced-motion"
checked={preferences.reducedMotion}
onCheckedChange={(checked) => updatePreference("reducedMotion", checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="high-contrast" className="text-sm">High contrast</Label>
<p className="text-xs text-[color:var(--ink-soft)]">Increase color contrast</p>
</div>
<Switch
id="high-contrast"
checked={preferences.highContrast}
onCheckedChange={(checked) => updatePreference("highContrast", checked)}
/>
</div>
</div>
</div>
<Separator />
{/* Experimental Features - Advanced */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
<Sparkles size={14} className="text-[color:var(--gold)]" />
Experimental
</Label>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="experimental-features" className="text-sm">Enable experimental features</Label>
<p className="text-xs text-[color:var(--ink-soft)]">Try new features before they&apos;re released</p>
</div>
<Switch
id="experimental-features"
checked={preferences.experimentalFeatures}
onCheckedChange={(checked) => updatePreference("experimentalFeatures", checked)}
/>
</div>
</div>
</>
) : null}
<Separator />
{/* Reset Section */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[color:var(--ink)]">Reset preferences</p>
<p className="text-xs text-[color:var(--ink-soft)]">Restore all settings to defaults</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
resetPreferences();
toast.success("Preferences reset to defaults");
}}
className="flex items-center gap-2"
>
<RotateCcw size={14} />
Reset
</Button>
</div>
</CardContent>
</Card>
{/* Organizations */}
<Card>
<CardHeader>
+6 -6
View File
@@ -112,6 +112,12 @@ export function SkillsIndex() {
return (
<main className="browse-page">
<div className="browse-page-header">
<h1 className="browse-title">
Skills
{totalSkillsText ? (
<span className="browse-count">{totalSkillsText}</span>
) : null}
</h1>
<button
className="browse-sidebar-toggle"
type="button"
@@ -120,12 +126,6 @@ export function SkillsIndex() {
>
Filters
</button>
<h1 className="browse-title">
Skills
{totalSkillsText ? (
<span className="browse-count">{totalSkillsText}</span>
) : null}
</h1>
</div>
<div className="browse-page-search">
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
+1 -1
View File
@@ -25,7 +25,7 @@ export const Route = createFileRoute("/souls/")({
function SoulsHoldingPage() {
return (
<main className="browse-page souls-coming-page">
<main className="section souls-coming-page">
<section className="souls-coming-hero">
<div>
<div className="skill-card-tags mb-3">
+7 -6
View File
@@ -5,6 +5,7 @@ import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Doc } from "../../convex/_generated/dataModel";
import { EmptyState } from "../components/EmptyState";
import { Container } from "../components/layout/Container";
import { SignInButton } from "../components/SignInButton";
import { Button } from "../components/ui/button";
import { formatCompactStat } from "../lib/numberFormat";
@@ -25,8 +26,8 @@ function Stars() {
if (!me) {
return (
<main className="browse-page">
<div className="browse-page-narrow">
<main className="py-10">
<Container size="narrow">
<EmptyState
icon={Star}
title="Sign in to see your highlights"
@@ -34,14 +35,14 @@ function Stars() {
>
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
</EmptyState>
</div>
</Container>
</main>
);
}
return (
<main className="browse-page">
<div className="browse-page-narrow">
<main className="py-10">
<Container size="narrow">
<div className="flex flex-col gap-6">
<header>
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)]">
@@ -104,7 +105,7 @@ function Stars() {
</div>
)}
</div>
</div>
</Container>
</main>
);
}
+2 -9
View File
@@ -1,17 +1,10 @@
import {
isTextContentType,
normalizeTextContentType,
TEXT_FILE_EXTENSION_SET,
} from "clawhub-schema/textFiles";
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from "clawhub-schema/textFiles";
import { getUserFacingConvexError } from "../../lib/convexError";
export async function uploadFile(uploadUrl: string, file: File) {
const path = file.webkitRelativePath || file.name;
const contentType =
normalizeTextContentType(path, file.type) ?? file.type ?? "application/octet-stream";
const response = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": contentType },
headers: { "Content-Type": file.type || "application/octet-stream" },
body: file,
});
if (!response.ok) {
+402 -1561
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -6,6 +6,7 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { nitro } from "nitro/vite";
import { defineConfig, type Plugin } from "vite";
import viteTsConfigPaths from "vite-tsconfig-paths";
const require = createRequire(import.meta.url);
@@ -165,8 +166,6 @@ const config = defineConfig({
"convex/values": convexValuesPath,
"@convex-dev/auth/react": convexAuthReactPath,
},
// Use native Vite tsconfig paths resolution instead of the plugin
tsconfigPaths: true,
},
optimizeDeps: {
include: ["convex/react", "convex/browser"],
@@ -180,6 +179,10 @@ const config = defineConfig({
onwarn: handleRollupWarning,
},
}),
// this is the plugin that enables path aliases
viteTsConfigPaths({
projects: ["./tsconfig.json"],
}),
tailwindcss(),
tanstackStart(),
viteReact(),