Compare commits

..
Author SHA1 Message Date
momothemage edb702bb57 feat: add org support to skill ownership transfers — changelog entry 2026-04-10 17:15:40 +08:00
momothemage 6c8ace3755 fix: clarify org-transfer sender deferral and surface org-targeted incoming transfers 2026-04-10 17:07:59 +08:00
Tomáš Ďuriš 162f4d24f8 fix: honor sender's target publisher on accept, disallow override
When the sender specified a target publisher (toPublisherId), the
acceptor's publisherId arg is now ignored. The publisher override
only applies to user-targeted transfers where the recipient chooses
where to assign ownership.
2026-04-10 17:07:59 +08:00
Tomáš Ďuriš 3e7deacce8 fix: validate target publisher is active before creating transfer
requestTransferInternal now checks that toPublisherId is not
deleted/deactivated before persisting the transfer. Prevents
creating dead transfers that can never be accepted.
2026-04-10 17:07:59 +08:00
Tomáš Ďuriš a90563d9d2 fix: accept empty JSON body on transfer accept and enforce org role on cancel
- Accept handler no longer rejects empty JSON bodies. If parsing fails
  (empty body, no content-type), publisherId stays undefined and the
  transfer proceeds with the default publisher. Fixes clients that
  send Content-Type: application/json with no body.
- Cancel always validates org membership when fromPublisherId is set,
  even for the original requester. Prevents cancelled-after-removal
  where a user who lost org admin role could still cancel.
2026-04-10 17:07:59 +08:00
Tomáš Ďuriš 74455bb860 fix: update transfer test for object body instead of pre-stringified JSON
The existing test expected body as a JSON string, but apiRequest
already calls JSON.stringify internally — passing a pre-stringified
body causes double-encoding. Updated test to verify the body object
directly, matching the corrected CLI behavior.
2026-04-10 17:07:59 +08:00
Tomáš Ďuriš 704d9457aa refactor: scope CLI to skill-only transfers in PR 1
Remove package auto-detection, --type flag, and package-specific
response schema fields. PR 1 only adds skill org support — package
transfer CLI support belongs in PR 2 alongside the backend endpoints.
2026-04-10 17:07:59 +08:00
Tomáš Ďuriš bf7a1d32c9 fix: address review feedback on skill transfer org support
- Add clarifying comment that ownerUserId reflects the accepting admin,
  not the original toUserId, for org-targeted transfers
- Remove redundant .map() type remapping in transfer listing — the
  query already returns type: "skill"
- Fix double JSON encoding in CLI transfer accept body
2026-04-10 17:07:59 +08:00
Tomáš Ďuriš d467ae3d75 feat: add org (publisher) support to skill ownership transfers
Add fromPublisherId/toPublisherId fields to skillOwnershipTransfers schema
and make toUserId optional to support org-to-org, user-to-org, and
org-to-user transfer flows.

- Extract shared transfer helpers into convex/lib/transfers.ts
- Update skillTransfers.ts with org-aware request/accept/reject/cancel
- Add publisher handle resolution and org admin fallback in skillsV1.ts
- Add type:"skill" field to transfer listing responses in transfersV1.ts
- Update CLI with --type and --publisher flags for transfer commands
- Update response schemas, docs (http-api, cli, orgs)
2026-04-10 17:07:59 +08:00
162 changed files with 5444 additions and 17868 deletions
-4
View File
@@ -27,13 +27,9 @@ jobs:
- name: Test
run: bun run test
env:
VITE_CONVEX_URL: https://example.invalid
- name: Coverage
run: bun run coverage
env:
VITE_CONVEX_URL: https://example.invalid
- name: ClawHub CLI Verify
run: bun run --cwd packages/clawhub verify
-4
View File
@@ -24,7 +24,3 @@ coverage
playwright-report
test-results
.playwright
convex/_generated/
skills-lock.json
*/skills/*
skills/*
-8
View File
@@ -87,11 +87,3 @@
- **32K document limit per query.** Split `.collect()` calls by a partition field (e.g., one day at a time instead of a 7-day range). See `rebuildTrendingLeaderboardAction` in `convex/leaderboards.ts` for an example.
- **Common mistakes**: `.filter().collect()` without an index; `ctx.db.get()` on large docs in a loop for list views; while loops that paginate the whole table to find filtered results.
- **Before writing or reviewing Convex queries, check deployment health.** Run `bunx convex insights` to check for OCC conflicts, `bytesReadLimit`, and `documentsReadLimit` errors. Run `bunx convex logs --failure` to see individual error messages and stack traces. This helps identify which functions are causing bandwidth issues so you can prioritize fixes.
<!-- convex-ai-start -->
This project uses [Convex](https://convex.dev) as its backend.
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
<!-- convex-ai-end -->
+1 -4
View File
@@ -5,10 +5,7 @@
### Changed
- Search: add CJK tokenization support (Chinese/Japanese/Korean) with Intl.Segmenter plus fallback behavior to improve skill query matching (#1596) (thanks @pq-dong).
### Fixes
- Stats maintenance: keep skill stat migration fields synchronized by treating top-level stat fields as canonical during backfill/reconcile fallback reads (#1704) (thanks @momothemage).
- Skill ownership transfers now support org targets: `transfer request` and `transfer accept` accept a `--publisher` flag to send or receive a skill on behalf of an org. Org admins can see incoming org-targeted transfers via `GET /api/v1/transfers/incoming`. (#1603 thanks @TommYDeeee)
## 0.10.0 - 2026-04-05
-8
View File
@@ -45,11 +45,3 @@
- Tests use `._handler` to call mutation handlers directly with mock `db` objects.
- Mock `db` objects MUST include `normalizeId: vi.fn()` for trigger wrapper compatibility.
<!-- convex-ai-start -->
This project uses [Convex](https://convex.dev) as its backend.
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
<!-- convex-ai-end -->
-356
View File
@@ -1,356 +0,0 @@
# ClawHub Design System
This document outlines the design rules, patterns, and guidelines for the ClawHub platform to ensure consistency, accessibility, and maintainability across all components.
---
## Color System
### Brand Palette (OpenClaw)
ClawHub uses a strict **3-5 color palette** based on the OpenClaw brand:
| Token | Light Mode | Dark Mode | Usage |
|-------|------------|-----------|-------|
| `--accent` | `#dc2626` | `#dc2626` | Primary actions, interactive elements, emphasis |
| `--accent-deep` | `#b91c1c` | `#ef4444` | Hover states, secondary emphasis |
| `--ink` | `#0a0a0a` | `#fafafa` | Primary text |
| `--ink-soft` | `#525252` | `#a1a1a1` | Secondary text, descriptions |
| `--surface` | `#ffffff` | `#121212` | Card backgrounds, elevated surfaces |
| `--bg` | `#fafafa` | `#0a0a0a` | Page background |
### Rules
1. **Never exceed 5 colors** without explicit design approval
2. **Never use purple/violet prominently** unless explicitly requested
3. **Always override text color** when changing background color to ensure contrast
4. **Use semantic tokens** (`--accent`, `--ink`, `--surface`) instead of raw colors
---
## Typography
### Font Stack
```css
--font-sans: 'Geist', system-ui, sans-serif;
--font-mono: 'Geist Mono', monospace;
--font-display: 'Geist', system-ui, sans-serif;
```
### Scale
| Token | Size | Usage |
|-------|------|-------|
| `--fs-xs` | 0.75rem (12px) | Labels, badges, metadata |
| `--fs-sm` | 0.875rem (14px) | Body text, descriptions |
| `--fs-base` | 1rem (16px) | Default body text |
| `--fs-md` | 1.125rem (18px) | Subheadings |
| `--fs-lg` | 1.25rem (20px) | Section titles |
| `--fs-xl` | 1.5rem (24px) | Page headings |
### Rules
1. **Maximum 2 font families** per page
2. **Line height 1.4-1.6** for body text (use `leading-relaxed`)
3. **Never use decorative fonts** for body text
4. **Minimum font size: 14px** for readability
5. Use `text-balance` or `text-pretty` for titles
---
## Layout
### Method Priority
Use this hierarchy for layout decisions:
1. **Flexbox** - Default for most layouts
2. **CSS Grid** - Only for complex 2D layouts (cards, galleries)
3. **Never use floats** or absolute positioning unless absolutely necessary
### Spacing Scale
```css
--space-1: 0.25rem /* 4px */
--space-2: 0.5rem /* 8px */
--space-3: 0.75rem /* 12px */
--space-4: 1rem /* 16px */
--space-5: 1.5rem /* 24px */
--space-6: 2rem /* 32px */
```
### Grid Patterns
#### Auto-fit Grid (Recommended for Cards)
```css
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
```
- Automatically adjusts columns based on container width
- Prevents orphan items on partial rows
- Maintains consistent card widths
#### Fixed Grid (When exact columns needed)
```css
/* 3-column at desktop, 2 at tablet, 1 at mobile */
grid-template-columns: repeat(3, minmax(0, 1fr));
@media (max-width: 860px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 520px) {
grid-template-columns: 1fr;
}
```
### Container Widths
| Size | Max Width | Usage |
|------|-----------|-------|
| Default | `--page-max` (1200px) | Standard pages |
| Narrow | `--page-narrow` (720px) | Reading content, forms |
| Wide | Full width | Dashboards, data tables |
---
## Components
### Cards
```css
.card {
padding: var(--space-4);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--surface);
}
```
**Rules:**
- Always use `display: flex; flex-direction: column;` for consistent height
- Add `flex: 1` to content area for equal-height cards in grids
- Include hover state with `border-color` and subtle `box-shadow`
### Buttons
| Variant | Usage |
|---------|-------|
| `primary` | Main actions (Submit, Save, Download) |
| `secondary` | Alternative actions |
| `ghost` | Tertiary actions, navigation |
| `destructive` | Delete, remove, dangerous actions |
**Rules:**
- Always include visible focus state
- Minimum touch target: 44x44px on mobile
- Include `aria-label` when icon-only
### Form Controls
- Labels above inputs (not inline)
- Error states use `--status-error-fg`
- Focus rings use `--accent` with 0.2 opacity
- Minimum input height: 40px
---
## Responsive Breakpoints
```css
/* Mobile first - base styles for mobile */
@media (min-width: 520px) {
/* Small tablets, large phones */
}
@media (min-width: 640px) {
/* Tablets */
}
@media (min-width: 860px) {
/* Small desktops, landscape tablets */
}
@media (min-width: 1024px) {
/* Desktops */
}
@media (min-width: 1280px) {
/* Large desktops */
}
```
### Rules
1. **Mobile-first approach** - Base styles target mobile
2. **Progressive enhancement** - Add complexity as viewport increases
3. **Test intermediate breakpoints** - Avoid jarring layout jumps
4. **Never hide critical content** on mobile
---
## Accessibility
### Color Contrast
- Normal text: Minimum 4.5:1 ratio
- Large text (18px+): Minimum 3:1 ratio
- Interactive elements: Minimum 3:1 ratio
### Focus States
```css
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 2px;
}
```
### Screen Readers
- Use `sr-only` class for visually hidden but accessible text
- Always include `alt` text for images (empty `alt=""` for decorative)
- Use semantic HTML elements (`main`, `nav`, `article`, `section`)
- Proper heading hierarchy (h1 > h2 > h3, no skipping)
### Motion
```css
/* Respect user preference */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
---
## Animation
### Timing
```css
--transition-fast: 150ms;
--transition-base: 200ms;
--transition-slow: 300ms;
```
### Easing
- Use `ease` or `ease-out` for most transitions
- Use `ease-in-out` for enter/exit animations
- Never use `linear` except for continuous animations
### Rules
1. **Subtle by default** - Avoid flashy animations
2. **Purpose-driven** - Animation should provide feedback
3. **Respect preferences** - Support `prefers-reduced-motion`
4. **Performance** - Use `transform` and `opacity` only
---
## Icons
### Usage
- Use Lucide icons consistently
- Standard sizes: 14px, 16px, 20px, 24px
- Include `aria-hidden="true"` for decorative icons
- Never use emojis as icons
### Placement
- Left of labels in buttons and navigation
- Right of labels for external links or dropdowns
- Centered when used alone with `aria-label`
---
## Dark Mode
### Implementation
```css
[data-theme="dark"] {
/* Dark mode overrides */
}
```
### Rules
1. Never use pure white (`#ffffff`) on dark backgrounds
2. Reduce shadow intensity in dark mode
3. Adjust image brightness if needed
4. Test contrast ratios in both modes
---
## Performance
### CSS
1. Use CSS custom properties for theming
2. Avoid deeply nested selectors (max 3 levels)
3. Use `will-change` sparingly
4. Prefer `transform` over `top/left` for animations
### Images
1. Always specify `width` and `height` attributes
2. Use `loading="lazy"` for below-fold images
3. Use appropriate formats (WebP with fallbacks)
4. Include placeholder or skeleton states
---
## Code Style
### CSS Class Naming
```css
/* Component */
.component-name { }
/* Component modifier */
.component-name.variant { }
/* Component child */
.component-name-child { }
/* State */
.component-name.is-active { }
.component-name[data-state="open"] { }
```
### File Organization
```
src/
components/
ui/ # Primitive components (Button, Input, Card)
layout/ # Layout components (Container, Header)
styles.css # Global styles and design tokens
lib/
theme.ts # Theme utilities
preferences.ts # User preference management
```
---
## Checklist
Before shipping any UI changes, verify:
- [ ] Color contrast meets WCAG AA standards
- [ ] Focus states are visible
- [ ] Layout works at all breakpoints
- [ ] Animations respect `prefers-reduced-motion`
- [ ] Text is readable at default browser zoom
- [ ] Interactive elements have 44px minimum touch target
- [ ] Semantic HTML is used appropriately
- [ ] Dark mode has been tested
+153 -210
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "clawhub",
@@ -19,8 +20,6 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -33,7 +32,6 @@
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "1.167.2",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clawhub-schema": "workspace:*",
"clsx": "^2.1.1",
"convex": "^1.34.1",
@@ -43,8 +41,6 @@
"ignore": "^7.0.5",
"lucide-react": "^0.577.0",
"monaco-editor": "^0.55.1",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"nitro": "3.0.260311-beta",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -55,7 +51,6 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"vite-tsconfig-paths": "^6.1.1",
"yaml": "^2.8.3",
"zod": "^4.3.6",
@@ -123,9 +118,9 @@
"@ark/util": ["@ark/util@0.56.0", "", {}, "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.10", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.0.1", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.6" } }, "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw=="],
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.0.9", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg=="],
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.0.4", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7" } }, "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w=="],
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
@@ -175,33 +170,33 @@
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
"@clack/core": ["@clack/core@1.2.0", "", { "dependencies": { "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg=="],
"@clack/core": ["@clack/core@1.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="],
"@clack/prompts": ["@clack/prompts@1.2.0", "", { "dependencies": { "@clack/core": "1.2.0", "fast-string-width": "^1.1.0", "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w=="],
"@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="],
"@convex-dev/auth": ["@convex-dev/auth@0.0.91", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", "cookie": "^1.0.1", "is-network-error": "^1.1.0", "jose": "^5.2.2", "jwt-decode": "^4.0.0", "lucia": "^3.2.0", "oauth4webapi": "^3.1.2", "path-to-regexp": "^6.3.0", "server-only": "^0.0.1" }, "peerDependencies": { "@auth/core": "^0.37.0", "convex": "^1.17.0", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["react"], "bin": { "auth": "dist/bin.cjs" } }, "sha512-wLD4hszo3IhhMkwPs6ozWf0cUauwmhOvjUVn0g//kC338n/jApOjeDYWKCrn/qYUkveyDsbag5zrY8mVzA09Qg=="],
"@create-markdown/core": ["@create-markdown/core@2.0.3", "", {}, "sha512-qAYukvE603z42OGZF1LzwxxkOVDksB76wXu+fnlKBzGizhR7uN3xHQO8PFFZDqjkZpaTrmtDd768qzl+Ir+3pQ=="],
"@create-markdown/core": ["@create-markdown/core@2.0.2", "", {}, "sha512-maA3zw9HkdOZORpKyvmxcRFTTOCpClLW01oAuVtzW7LvafHippRz67VHngIBEsIPKEO5j4COItwXKFnzo9/dfA=="],
"@create-markdown/preview": ["@create-markdown/preview@2.0.3", "", { "peerDependencies": { "@create-markdown/core": ">=2.0.3", "shiki": ">=1.0.0" }, "optionalPeers": ["@create-markdown/core", "shiki"] }, "sha512-Vrp8DyuiouryZ3E4NQ7tBgoYQdoekd0+DzN64mZ48QYCw3V+MCb/H2q10SW8KC8XPr931XOMDvKX4I83qpQh3g=="],
"@create-markdown/preview": ["@create-markdown/preview@2.0.2", "", { "peerDependencies": { "@create-markdown/core": ">=2.0.2", "mermaid": ">=10.0.0", "shiki": ">=1.0.0" }, "optionalPeers": ["@create-markdown/core", "mermaid", "shiki"] }, "sha512-ty1mp7qXVI0Bap8M0jiDiJsAqZkP3oaYNp0JX0wiY4K+KfWgK4IqeB8R2W+9vLpRxzxhX6Rggf5Qj2Sv5p75Eg=="],
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
"@csstools/css-calc": ["@csstools/css-calc@3.2.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w=="],
"@csstools/css-calc": ["@csstools/css-calc@3.1.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ=="],
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.0", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.2.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ=="],
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.0.2", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.1.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw=="],
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.3", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg=="],
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.1", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w=="],
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
"@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="],
@@ -271,56 +266,6 @@
"@fontsource/manrope": ["@fontsource/manrope@5.2.8", "", {}, "sha512-gJHJmcuUk7qWcNCfcAri/DJQtXtBYqi9yKratr4jXhSo0I3xUtNNKI+igQIcw5c+m95g0vounk8ZnX/kb8o0TA=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -335,25 +280,7 @@
"@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="],
"@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=="],
"@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=="],
"@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=="],
@@ -423,43 +350,43 @@
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.17.4", "", { "os": "win32", "cpu": "x64" }, "sha512-JxT81aEUBNA/s01Ql2OQ2DLAsuM0M+mK9iLHunukOdPMhjA6NvFE/GtTablBYJKScK21d/xTvnoSLgQU3l22Cw=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-YdeJKaZckDQL1qa62a1aKq/goyq48aX3yOxaaWqWb4sau4Ee4IiLbamftNLU3zbePky6QsDj6thnSSzHRBjDfA=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.58.0", "", { "os": "android", "cpu": "arm" }, "sha512-1T7UN3SsWWxpWyWGn1cT3ASNJOo+pI3eUkmEl7HgtowapcV8kslYpFQcYn431VuxghXakPNlbjRwhqmR37PFOg=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-7ANS7PpXCfq84xZQ8E5WPs14gwcuPcl+/8TFNXfpSu0CQBXz3cUo2fDpHT8v8HJN+Ut02eacvMAzTnc9s6X4tw=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.58.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GryzujxuiRv2YFF7bRy8mKcxlbuAN+euVUtGJt9KKbLT8JBUIosamVhcthLh+VEr6KE6cjeVMAQxKAzJcoN7dg=="],
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.60.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pJsgd9AfplLGBm1fIr25V6V14vMrayhx4uIQvlfH7jWs2SZwSrvi3TfgfJySB8T+hvyEH8K2zXljQiUnkgUnfQ=="],
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.58.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7/bRSJIwl4GxeZL9rPZ11anNTyUO9epZrfEJH/ZMla3+/gbQ6xZixh9nOhsZ0QwsTW7/5J2A/fHbD1udC5DQQA=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.60.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ue1aXHX49ivwflKqGJc7zcd/LeLgbhaTcDCQStgx5x06AXgjEAZmvrlMuIkWd4AL4FHQe6QJ9f33z04Cg448VQ=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.58.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-EqdtJSiHweS2vfILNrpyJ6HUwpEq2g7+4Zx1FPi4hu3Hu7tC3znF6ufbXO8Ub2LD4mGgznjI7kSdku9NDD1Mkg=="],
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.60.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YCyQzsQtusQw+gNRW9rRTifSO+Dt/+dtCl2NHoDMZqJlRTEZ/Oht9YnuporI9yiTx7+cB+eqzX3MtHHVHGIWhg=="],
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.58.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-VQt5TH4M42mY20F545G637RKxV/yjwVtKk2vfXuazfReSIiuvWBnv+FVSvIV5fKVTJNjt3GSJibh6JecbhGdBw=="],
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-c7dxM2Zksa45Qw16i2iGY3Fti2NirJ38FrsBsKw+qcJ0OtqTsBgKJLF0xV+yLG56UH01Z8WRPgsw31e0MoRoGQ=="],
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.58.0", "", { "os": "linux", "cpu": "arm" }, "sha512-fBYcj4ucwpAtjJT3oeBdFBYKvNyjRSK+cyuvBOTQjh0jvKp4yeA4S/D0IsCHus/VPaNG5L48qQkh+Vjy3HL2/Q=="],
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ZWALoA42UYqBEP1Tbw9OWURgFGS1nWj2AAvLdY6ZcGx/Gj93qVCBKjcvwXMupZibYwFbi9s/rzqkZseb/6gVtQ=="],
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.58.0", "", { "os": "linux", "cpu": "arm" }, "sha512-0BeuFfwlUHlJ1xpEdSD1YO3vByEFGPg36uLjK1JgFaxFb4W6w17F8ET8sz5cheZ4+x5f2xzdnRrrWv83E3Yd8g=="],
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tpy+1w4p9hN5CicMCxqNy6ymfRtV5ayE573vFNjp1k1TN/qhLFgflveZoE/0++RlkHikBz2vY545NWm/hp7big=="],
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.58.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TXlZgnPTlxrQzxG9ZXU7BNwx1Ilrr17P3GwZY0If2EzrinqRH3zXPc3HrRcBJgcsoZNMuNL5YivtkJYgp467UQ=="],
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-eDYDXZGhQAXyn6GwtwiX/qcLS0HlOLPJ/+iiIY8RYr+3P8oKBmgKxADLlniL6FtWfE7pPk7IGN9/xvDEvDvFeg=="],
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.58.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zSoYRo5dxHLcUx93Stl2hW3hSNjPt99O70eRVWt5A1zwJ+FPjeCCANCD2a9R4JbHsdcl11TIQOjyigcRVOH2mw=="],
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nxehly5XYBHUWI9VJX1bqCf9j/B43DaK/aS/T1fcxCpX3PA4Rm9BB54nPD1CKayT8xg6REN1ao+01hSRNgy8OA=="],
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.58.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NQ0U/lqxH2/VxBYeAIvMNUK1y0a1bJ3ZicqkF2c6wfakbEciP9jvIE4yNzCFpZaqeIeRYaV7AVGqEO1yrfVPjA=="],
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-j1qf/NaUfOWQutjeoooNG1Q0zsK0XGmSu1uDLq3cctquRF3j7t9Hxqf/76ehCc5GEUAanth2W4Fa+XT1RFg/nw=="],
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.58.0", "", { "os": "linux", "cpu": "none" }, "sha512-X9J+kr3gIC9FT8GuZt0ekzpNUtkBVzMVU4KiKDSlocyQuEgi3gBbXYN8UkQiV77FTusLDPsovjo95YedHr+3yg=="],
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-YELKPRefQ/q/h3RUmeRfPCUhh2wBvgV1RyZ/F9M9u8cDyXsQW2ojv1DeWQTt466yczDITjZnIOg/s05pk7Ve2A=="],
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.58.0", "", { "os": "linux", "cpu": "none" }, "sha512-CDze3pi1OO3Wvb/QsXjmLEY4XPKGM6kIo82ssNOgmcl1IdndF9VSGAE38YLhADWmOac7fjqhBw82LozuUVxD0Q=="],
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.60.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-JkO3C6Gki7Y6h/MiIkFKvHFOz98/YWvQ4WYbK9DLXACMP2rjULzkeGyAzorJE5S1dzLQGFgeqvN779kSFwoV1g=="],
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.58.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-b/89glbxFaEAcA6Uf1FvCNecBJEgcUTsV1quzrqXM/o4R1M4u+2KCVuyGCayN2UpsRWtGGLb+Ver0tBBpxaPog=="],
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XjKHdFVCpZZZSWBCKyyqCq65s2AKXykMXkjLoKYODrD+f5toLhlwsMESscu8FbgnJQ4Y/dpR/zdazsahmgBJIA=="],
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.58.0", "", { "os": "linux", "cpu": "x64" }, "sha512-0/yYpkq9VJFCEcuRlrViGj8pJUFFvNS4EkEREaN7CB1EcLXJIaVSSa5eCihwBGXtOZxhnblWgxks9juRdNQI7w=="],
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-js29ZWIuPhNWzY8NC7KoffEMEeWG105vbmm+8EOJsC+T/jHBiKIJEUF78+F/IrgEWMMP9N0kRND4Pp75+xAhKg=="],
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.58.0", "", { "os": "linux", "cpu": "x64" }, "sha512-hr6FNvmcAXiH+JxSvaJ4SJ1HofkdqEElXICW9sm3/Rd5eC3t7kzvmLyRAB3NngKO2wzXRCAm4Z/mGWfrsS4X8w=="],
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.60.0", "", { "os": "none", "cpu": "arm64" }, "sha512-H+PUITKHk04stFpWj3x3Kg08Afp/bcXSBi0EhasR5a0Vw7StXHTzdl655PUI0fB4qdh2Wsu6Dsi+3ACxPoyQnA=="],
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.58.0", "", { "os": "none", "cpu": "arm64" }, "sha512-R+O368VXgRql1K6Xar+FEo7NEwfo13EibPMoTv3sesYQedRXd6m30Dh/7lZMxnrQVFfeo4EOfYIP4FpcgWQNHg=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.60.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-WA/yc7f7ZfCefBXVzNHn1Ztulb1EFwNBb4jMZ6pjML0zz6pHujlF3Q3jySluz3XHl/GNeMTntG1seUBWVMlMag=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.58.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q0FZiAY/3c4YRj4z3h9K1PgaByrifrfbBoODSeX7gy97UtB7pySPUQfC2B/GbxWU6k7CzQrRy5gME10PltLAFQ=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.60.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-33YxL1sqwYNZXtn3MD/4dno6s0xeedXOJlT1WohkVD565WvohClZUr7vwKdAk954n4xiEWJkewiCr+zLeq7AeA=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.58.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-Y8FKBABrSPp9H0QkRLHDHOSUgM/309a3IvOVgPcVxYcX70wxJrk608CuTg7w+C6vEd724X5wJoNkBcGYfH7nNQ=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-JOro4ZcfBLamJCyfURQmOQByoorgOdx3ZjAkSqnb/CyG/i+lN3KoV5LAgk5ZAW6DPq7/Cx7n23f8DuTWXTWgyQ=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.58.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bCn5rbiz5My+Bj7M09sDcnqW0QJyINRVxdZ65x1/Y2tGrMwherwK/lpk+HRQCKvXa8pcaQdF5KY5j54VGZLwNg=="],
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
@@ -513,9 +440,7 @@
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
@@ -611,8 +536,6 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
@@ -669,7 +592,7 @@
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.166.16", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-router": "1.168.1", "@tanstack/router-core": "1.168.1", "@tanstack/start-client-core": "1.167.1", "@tanstack/start-server-core": "1.167.1" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-YEuM5XSxNQhLr30e6uyep7m5yZHtZwCeEeQVyo7CSWKmUpkBtN60+bg4T2/nLY0MXrwo6DTK1Crsu80ZZLkPAA=="],
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
"@tanstack/react-store": ["@tanstack/react-store@0.9.2", "", { "dependencies": { "@tanstack/store": "0.9.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
@@ -693,7 +616,7 @@
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.166.15", "", { "dependencies": { "@tanstack/router-core": "1.168.1" } }, "sha512-mGDNfJo/eFtwgFFBrJ85rNdIBNTroE3zy5zbwHZ/FV0HPYOawnev7KscDjKBuVxBGY2jl0fQLrRNUO/Sjqy3cg=="],
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
"@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
@@ -723,7 +646,7 @@
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
@@ -741,21 +664,21 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.4", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.4", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.4", "vitest": "4.1.4" }, "optionalPeers": ["@vitest/browser"] }, "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.2", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.2", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.2", "vitest": "4.1.2" }, "optionalPeers": ["@vitest/browser"] }, "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg=="],
"@vitest/expect": ["@vitest/expect@4.1.4", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.4", "@vitest/utils": "4.1.4", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww=="],
"@vitest/expect": ["@vitest/expect@4.1.2", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ=="],
"@vitest/mocker": ["@vitest/mocker@4.1.4", "", { "dependencies": { "@vitest/spy": "4.1.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg=="],
"@vitest/mocker": ["@vitest/mocker@4.1.2", "", { "dependencies": { "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.4", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.2", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA=="],
"@vitest/runner": ["@vitest/runner@4.1.4", "", { "dependencies": { "@vitest/utils": "4.1.4", "pathe": "^2.0.3" } }, "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ=="],
"@vitest/runner": ["@vitest/runner@4.1.2", "", { "dependencies": { "@vitest/utils": "4.1.2", "pathe": "^2.0.3" } }, "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.4", "", { "dependencies": { "@vitest/pretty-format": "4.1.4", "@vitest/utils": "4.1.4", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A=="],
"@vitest/spy": ["@vitest/spy@4.1.4", "", {}, "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ=="],
"@vitest/spy": ["@vitest/spy@4.1.2", "", {}, "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA=="],
"@vitest/utils": ["@vitest/utils@4.1.4", "", { "dependencies": { "@vitest/pretty-format": "4.1.4", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw=="],
"@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -787,7 +710,7 @@
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.10", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
@@ -797,9 +720,9 @@
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001780", "", {}, "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
@@ -821,8 +744,6 @@
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clawhub": ["clawhub@workspace:packages/clawhub"],
"clawhub-schema": ["clawhub-schema@workspace:packages/schema"],
@@ -831,8 +752,6 @@
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
@@ -843,15 +762,15 @@
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"convex": ["convex@1.35.1", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "@clerk/react": "^6.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "@clerk/react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-g23KrTjBiXqRHzWIN0PVFagKjrmFxWUaOSiBsAWPTpXX2rXl0L1F4PR0YpAcMJEzMgfZR9AGymJvLTM+KA6lsQ=="],
"convex": ["convex@1.34.1", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-ooyFnZVVq0u6b5zt0Ptq8QB2ixhf/2vXe+PIcUtdtrs0lq/TwpkmmruHdqkFmWgMd6N+Tmfy8AGkz6QnZUYZBA=="],
"convex-helpers": ["convex-helpers@0.1.114", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.32.0", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-elEdh+gG6BDv2dWIWVvBeJPbHnDQS5+WexUuwlGVJXz1EbMkXz/UIQwFIfLMZIXUwW6ot4JYf/1JJKNStrE6lg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cookie-es": ["cookie-es@2.0.1", "", {}, "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA=="],
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
"crossws": ["crossws@0.4.5", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-wUR89x/Rw7/8t+vn0CmGDYM9TD6VtARGb0LD5jq2wjtMy1vCP4M+sm6N6TigWeTYvnA8MoW29NqqXD0ep0rfBA=="],
"crossws": ["crossws@0.4.4", "", { "peerDependencies": { "srvx": ">=0.7.1" }, "optionalPeers": ["srvx"] }, "sha512-w6c4OdpRNnudVmcgr7brb/+/HmYjMQvYToO/oTrprTwxRUiom3LYWU1PMWuD006okbUWpII1Ea9/+kwpUfmyRg=="],
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
@@ -881,7 +800,7 @@
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
@@ -895,7 +814,7 @@
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"electron-to-chromium": ["electron-to-chromium@1.5.336", "", {}, "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.321", "", {}, "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ=="],
"encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
@@ -903,7 +822,7 @@
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"env-runner": ["env-runner@0.1.7", "", { "dependencies": { "crossws": "^0.4.4", "exsolve": "^1.0.8", "httpxy": "^0.5.0", "srvx": "^0.11.13" }, "peerDependencies": { "@netlify/runtime": "^4", "miniflare": "^4.20260317.3" }, "optionalPeers": ["@netlify/runtime", "miniflare"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-i7h96jxETJYhXy5grgHNJ9xNzCzWIn9Ck/VkkYgOlE4gOqknsLX3CmlVb5LmwNex8sOoLFVZLz+TIw/+b5rktA=="],
"env-runner": ["env-runner@0.1.6", "", { "dependencies": { "crossws": "^0.4.4", "httpxy": "^0.3.1", "srvx": "^0.11.9" }, "peerDependencies": { "miniflare": "^4.0.0" }, "optionalPeers": ["miniflare"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-fSb7X1zdda8k6611a6/SdSQpDe7a/bqMz2UWdbHjk9YWzpUR4/fn9YtE/hqgGQ2nhvVN0zUtcL1SRMKwIsDbAA=="],
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
@@ -925,12 +844,6 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-string-truncated-width": ["fast-string-truncated-width@1.2.1", "", {}, "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow=="],
"fast-string-width": ["fast-string-width@1.1.0", "", { "dependencies": { "fast-string-truncated-width": "^1.2.0" } }, "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ=="],
"fast-wrap-ansi": ["fast-wrap-ansi@0.1.6", "", { "dependencies": { "fast-string-width": "^1.1.0" } }, "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
@@ -979,7 +892,7 @@
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
"httpxy": ["httpxy@0.5.0", "", {}, "sha512-qwX7QX/rK2visT10/b7bSeZWQOMlSm3svTD0pZpU+vJjNUP0YHtNv4c3z+MO+MSnGuRFWJFdCZiV+7F7dXIOzg=="],
"httpxy": ["httpxy@0.3.1", "", {}, "sha512-XjG/CEoofEisMrnFr0D6U6xOZ4mRfnwcYQ9qvvnT4lvnX8BoeA3x3WofB75D+vZwpaobFVkBIHrZzoK40w8XSw=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
@@ -1013,7 +926,7 @@
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
"isbot": ["isbot@5.1.38", "", {}, "sha512-Cus2702JamTNMEY4zTP+TShgq/3qzjvGcBC4XMOV45BLaxD4iUFENkqu7ZhFeSzwNsCSZLjnGlihDQznnpnEEA=="],
"isbot": ["isbot@5.1.36", "", {}, "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
@@ -1029,7 +942,7 @@
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsdom": ["jsdom@29.0.2", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.5", "@asamuzakjp/dom-selector": "^7.0.6", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w=="],
"jsdom": ["jsdom@29.0.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.0.1", "@asamuzakjp/dom-selector": "^7.0.3", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@@ -1067,7 +980,7 @@
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="],
"lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
"lucia": ["lucia@3.2.2", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0" } }, "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA=="],
@@ -1183,15 +1096,11 @@
"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.16", "", {}, "sha512-Gs0xRPpUm2nDkqbi40NJ9g7qDIcjcJzgExiydnq6LAyqhI2jfno8wG3NKTL+IiJsx799UHOb1CnSd4Wg4SG4Pw=="],
"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=="],
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@@ -1219,7 +1128,7 @@
"oxfmt": ["oxfmt@0.41.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.41.0", "@oxfmt/binding-android-arm64": "0.41.0", "@oxfmt/binding-darwin-arm64": "0.41.0", "@oxfmt/binding-darwin-x64": "0.41.0", "@oxfmt/binding-freebsd-x64": "0.41.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.41.0", "@oxfmt/binding-linux-arm-musleabihf": "0.41.0", "@oxfmt/binding-linux-arm64-gnu": "0.41.0", "@oxfmt/binding-linux-arm64-musl": "0.41.0", "@oxfmt/binding-linux-ppc64-gnu": "0.41.0", "@oxfmt/binding-linux-riscv64-gnu": "0.41.0", "@oxfmt/binding-linux-riscv64-musl": "0.41.0", "@oxfmt/binding-linux-s390x-gnu": "0.41.0", "@oxfmt/binding-linux-x64-gnu": "0.41.0", "@oxfmt/binding-linux-x64-musl": "0.41.0", "@oxfmt/binding-openharmony-arm64": "0.41.0", "@oxfmt/binding-win32-arm64-msvc": "0.41.0", "@oxfmt/binding-win32-ia32-msvc": "0.41.0", "@oxfmt/binding-win32-x64-msvc": "0.41.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-sKLdJZdQ3bw6x9qKiT7+eID4MNEXlDHf5ZacfIircrq6Qwjk0L6t2/JQlZZrVHTXJawK3KaMuBoJnEJPcqCEdg=="],
"oxlint": ["oxlint@1.60.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.60.0", "@oxlint/binding-android-arm64": "1.60.0", "@oxlint/binding-darwin-arm64": "1.60.0", "@oxlint/binding-darwin-x64": "1.60.0", "@oxlint/binding-freebsd-x64": "1.60.0", "@oxlint/binding-linux-arm-gnueabihf": "1.60.0", "@oxlint/binding-linux-arm-musleabihf": "1.60.0", "@oxlint/binding-linux-arm64-gnu": "1.60.0", "@oxlint/binding-linux-arm64-musl": "1.60.0", "@oxlint/binding-linux-ppc64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-musl": "1.60.0", "@oxlint/binding-linux-s390x-gnu": "1.60.0", "@oxlint/binding-linux-x64-gnu": "1.60.0", "@oxlint/binding-linux-x64-musl": "1.60.0", "@oxlint/binding-openharmony-arm64": "1.60.0", "@oxlint/binding-win32-arm64-msvc": "1.60.0", "@oxlint/binding-win32-ia32-msvc": "1.60.0", "@oxlint/binding-win32-x64-msvc": "1.60.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-tnRzTWiWJ9pg3ftRWnD0+Oqh78L6ZSwcEudvCZaER0PIqiAnNyXj5N1dPwjmNpDalkKS9m/WMLN1CTPUBPmsgw=="],
"oxlint": ["oxlint@1.58.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.58.0", "@oxlint/binding-android-arm64": "1.58.0", "@oxlint/binding-darwin-arm64": "1.58.0", "@oxlint/binding-darwin-x64": "1.58.0", "@oxlint/binding-freebsd-x64": "1.58.0", "@oxlint/binding-linux-arm-gnueabihf": "1.58.0", "@oxlint/binding-linux-arm-musleabihf": "1.58.0", "@oxlint/binding-linux-arm64-gnu": "1.58.0", "@oxlint/binding-linux-arm64-musl": "1.58.0", "@oxlint/binding-linux-ppc64-gnu": "1.58.0", "@oxlint/binding-linux-riscv64-gnu": "1.58.0", "@oxlint/binding-linux-riscv64-musl": "1.58.0", "@oxlint/binding-linux-s390x-gnu": "1.58.0", "@oxlint/binding-linux-x64-gnu": "1.58.0", "@oxlint/binding-linux-x64-musl": "1.58.0", "@oxlint/binding-openharmony-arm64": "1.58.0", "@oxlint/binding-win32-arm64-msvc": "1.58.0", "@oxlint/binding-win32-ia32-msvc": "1.58.0", "@oxlint/binding-win32-x64-msvc": "1.58.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-t4s9leczDMqlvOSjnbCQe7gtoLkWgBGZ7sBdCJ9EOj5IXFSG/X7OAzK4yuH4iW+4cAYe8kLFbC8tuYMwWZm+Cg=="],
"oxlint-tsgolint": ["oxlint-tsgolint@0.17.4", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.17.4", "@oxlint-tsgolint/darwin-x64": "0.17.4", "@oxlint-tsgolint/linux-arm64": "0.17.4", "@oxlint-tsgolint/linux-x64": "0.17.4", "@oxlint-tsgolint/win32-arm64": "0.17.4", "@oxlint-tsgolint/win32-x64": "0.17.4" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-4F/NXJiK2KnK4LQiULUPXRzVq0LOfextGvwCVRW1VKQbF5epI3MDMEGVAl5XjAGL6IFc7xBc/eVA95wczPeEQg=="],
@@ -1239,19 +1148,19 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
"postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
"preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
"prettier": ["prettier@3.8.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
@@ -1259,9 +1168,9 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
@@ -1309,14 +1218,12 @@
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"seroval": ["seroval@1.5.2", "", {}, "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q=="],
"seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="],
"seroval-plugins": ["seroval-plugins@1.5.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg=="],
"seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="],
"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=="],
@@ -1327,7 +1234,7 @@
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="],
"solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
@@ -1337,7 +1244,7 @@
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"srvx": ["srvx@0.11.15", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg=="],
"srvx": ["srvx@0.11.12", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-AQfrGqntqVPXgP03pvBDN1KyevHC+KmYVqb8vVf4N+aomQqdhaZxjvoVp+AOm4u6x+GgNQY3MVzAUIn+TqwkOA=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
@@ -1357,8 +1264,6 @@
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
@@ -1367,7 +1272,7 @@
"tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
@@ -1375,17 +1280,17 @@
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"tldts": ["tldts@7.0.28", "", { "dependencies": { "tldts-core": "^7.0.28" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw=="],
"tldts": ["tldts@7.0.27", "", { "dependencies": { "tldts-core": "^7.0.27" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg=="],
"tldts-core": ["tldts-core@7.0.28", "", {}, "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ=="],
"tldts-core": ["tldts-core@7.0.27", "", {}, "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
@@ -1403,15 +1308,13 @@
"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=="],
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
"undici": ["undici@7.24.7", "", {}, "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
@@ -1447,9 +1350,9 @@
"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=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
"vitest": ["vitest@4.1.4", "", { "dependencies": { "@vitest/expect": "4.1.4", "@vitest/mocker": "4.1.4", "@vitest/pretty-format": "4.1.4", "@vitest/runner": "4.1.4", "@vitest/snapshot": "4.1.4", "@vitest/spy": "4.1.4", "@vitest/utils": "4.1.4", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.4", "@vitest/browser-preview": "4.1.4", "@vitest/browser-webdriverio": "4.1.4", "@vitest/coverage-istanbul": "4.1.4", "@vitest/coverage-v8": "4.1.4", "@vitest/ui": "4.1.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg=="],
"vitest": ["vitest@4.1.2", "", { "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", "@vitest/pretty-format": "4.1.2", "@vitest/runner": "4.1.2", "@vitest/snapshot": "4.1.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.2", "@vitest/browser-preview": "4.1.2", "@vitest/browser-webdriverio": "4.1.2", "@vitest/ui": "4.1.2", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
@@ -1497,14 +1400,10 @@
"@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-dropdown-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
@@ -1517,14 +1416,14 @@
"@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
@@ -1537,12 +1436,6 @@
"@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-switch/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
@@ -1557,17 +1450,15 @@
"@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.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
"@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=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="],
"@tailwindcss/oxide-wasm32-wasi/@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" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@@ -1585,19 +1476,23 @@
"@tanstack/start-plugin-core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
"cheerio/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"cheerio/undici": ["undici@7.24.5", "", {}, "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q=="],
"cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"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=="],
"jsdom/undici": ["undici@7.24.5", "", {}, "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q=="],
"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=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
@@ -1607,7 +1502,7 @@
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -1615,30 +1510,78 @@
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@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=="],
"vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"@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=="],
"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=="],
"@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=="],
"nitro/rolldown/@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
"@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=="],
"nitro/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
"@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=="],
"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=="],
"@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=="],
"nitro/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
"@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=="],
"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=="],
"@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=="],
"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=="],
"@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=="],
"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=="],
"@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=="],
"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=="],
"@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=="],
"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=="],
"@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=="],
"nitro/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
"@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=="],
"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=="],
}
}
-2
View File
@@ -100,7 +100,6 @@ import type * as publishers from "../publishers.js";
import type * as rateLimits from "../rateLimits.js";
import type * as search from "../search.js";
import type * as seed from "../seed.js";
import type * as seedDemo from "../seedDemo.js";
import type * as seedSouls from "../seedSouls.js";
import type * as skillStatEvents from "../skillStatEvents.js";
import type * as skillTransfers from "../skillTransfers.js";
@@ -217,7 +216,6 @@ declare const fullApi: ApiFromModules<{
rateLimits: typeof rateLimits;
search: typeof search;
seed: typeof seed;
seedDemo: typeof seedDemo;
seedSouls: typeof seedSouls;
skillStatEvents: typeof skillStatEvents;
skillTransfers: typeof skillTransfers;
-5
View File
@@ -469,11 +469,6 @@ export const seedSkillMutation = internalMutation({
createdAt: now,
updatedAt: now,
});
await ctx.db.patch(userId, {
publishedSkills: 1,
totalStars: 0,
totalDownloads: 0,
});
const versionId = await ctx.db.insert("skillVersions", {
skillId,
-193
View File
@@ -1197,154 +1197,6 @@ describe("httpApiV1 handlers", () => {
expect(json.version.security.virustotalUrl).toContain("virustotal.com/gui/file/");
});
it("surfaces static-scan suspicious status in version security snapshot", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("slug" in args) {
return {
skill: { _id: "skills:1", slug: "demo", displayName: "Demo" },
latestVersion: null,
owner: { handle: "owner", displayName: "Owner", image: null },
};
}
if ("skillId" in args && "version" in args) {
return {
version: "1.0.0",
createdAt: 1,
changelog: "c",
changelogSource: "auto",
sha256hash: "a".repeat(64),
staticScan: {
status: "suspicious",
reasonCodes: ["suspicious.dangerous_exec"],
summary: "Detected: suspicious.dangerous_exec",
engineVersion: "v2.4.0",
checkedAt: 555,
},
vtAnalysis: {
status: "clean",
verdict: "benign",
checkedAt: 111,
},
llmAnalysis: {
status: "completed",
verdict: "benign",
checkedAt: 222,
},
files: [],
};
}
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/skills/demo/versions/1.0.0"),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.version.security.status).toBe("suspicious");
expect(json.version.security.hasWarnings).toBe(true);
expect(json.version.security.hasScanResult).toBe(true);
expect(json.version.security.scanners.static.normalizedStatus).toBe("suspicious");
expect(json.version.security.scanners.vt.normalizedStatus).toBe("clean");
expect(json.version.security.scanners.llm.normalizedStatus).toBe("clean");
});
it("lets static-scan malicious status dominate benign vt and llm results", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("slug" in args) {
return {
skill: { _id: "skills:1", slug: "demo", displayName: "Demo" },
latestVersion: null,
owner: { handle: "owner", displayName: "Owner", image: null },
};
}
if ("skillId" in args && "version" in args) {
return {
version: "1.0.0",
createdAt: 1,
changelog: "c",
changelogSource: "auto",
sha256hash: "a".repeat(64),
staticScan: {
status: "malicious",
reasonCodes: ["malicious.credential_harvest"],
summary: "Detected: malicious.credential_harvest",
engineVersion: "v2.4.0",
checkedAt: 555,
},
vtAnalysis: {
status: "clean",
verdict: "benign",
checkedAt: 111,
},
llmAnalysis: {
status: "completed",
verdict: "benign",
checkedAt: 222,
},
files: [],
};
}
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/skills/demo/versions/1.0.0"),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.version.security.status).toBe("malicious");
expect(json.version.security.hasWarnings).toBe(true);
expect(json.version.security.hasScanResult).toBe(true);
expect(json.version.security.checkedAt).toBe(555);
expect(json.version.security.scanners.static.normalizedStatus).toBe("malicious");
});
it("treats a static scan by itself as a definitive scan result", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("slug" in args) {
return {
skill: { _id: "skills:1", slug: "demo", displayName: "Demo" },
latestVersion: null,
owner: { handle: "owner", displayName: "Owner", image: null },
};
}
if ("skillId" in args && "version" in args) {
return {
version: "1.0.0",
createdAt: 1,
changelog: "c",
changelogSource: "auto",
staticScan: {
status: "clean",
reasonCodes: [],
summary: "No issues found",
engineVersion: "v2.4.0",
checkedAt: 555,
},
files: [],
};
}
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/skills/demo/versions/1.0.0"),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.version.security.status).toBe("clean");
expect(json.version.security.hasWarnings).toBe(false);
expect(json.version.security.hasScanResult).toBe(true);
expect(json.version.security.virustotalUrl).toBeNull();
expect(json.version.security.scanners.static.normalizedStatus).toBe("clean");
expect(json.version.security.scanners.vt).toBeNull();
expect(json.version.security.scanners.llm).toBeNull();
});
it("keeps hasWarnings true when llm dimensions include non-ok ratings", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("slug" in args) {
@@ -2700,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,
+77 -54
View File
@@ -1,5 +1,4 @@
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";
@@ -71,11 +70,6 @@ type PublicSkillVersionParsed = {
clawdis?: { os?: string[]; nix?: { plugin?: boolean; systems?: string[] } };
};
type PublicSkillVersionStaticScan = Pick<
NonNullable<Doc<"skillVersions">["staticScan"]>,
"status" | "reasonCodes" | "summary" | "engineVersion" | "checkedAt"
>;
type PublicSkillVersionResponse = {
_id: Id<"skillVersions">;
version: string;
@@ -88,7 +82,6 @@ type PublicSkillVersionResponse = {
sha256hash?: string;
vtAnalysis?: Doc<"skillVersions">["vtAnalysis"];
llmAnalysis?: Doc<"skillVersions">["llmAnalysis"];
staticScan?: PublicSkillVersionStaticScan;
capabilityTags?: string[];
};
@@ -200,14 +193,6 @@ type SkillSecuritySnapshot = {
virustotalUrl: string | null;
capabilityTags: string[];
scanners: {
static: {
status: string;
normalizedStatus: NormalizedSecurityStatus;
reasonCodes: string[];
summary: string | null;
engineVersion: string | null;
checkedAt: number | null;
} | null;
vt: {
status: string;
verdict: string | null;
@@ -291,35 +276,30 @@ function hasLlmDimensionWarnings(
function buildSkillSecuritySnapshot(
version: Pick<
PublicSkillVersionResponse,
"sha256hash" | "vtAnalysis" | "llmAnalysis" | "staticScan" | "capabilityTags"
"sha256hash" | "vtAnalysis" | "llmAnalysis" | "capabilityTags"
>,
): SkillSecuritySnapshot | null {
const capabilityTags = version.capabilityTags ?? [];
const sha256hash = version.sha256hash ?? null;
const vt = version.vtAnalysis;
const llm = version.llmAnalysis;
const staticScan = version.staticScan;
if (!sha256hash && !vt && !llm && !staticScan && capabilityTags.length === 0) return null;
if (!sha256hash && !vt && !llm && capabilityTags.length === 0) return null;
const staticStatus = staticScan ? normalizeSecurityStatus(staticScan.status) : null;
const vtStatus = vt ? normalizeSecurityStatus(vt.verdict ?? vt.status) : null;
const llmStatus = llm ? normalizeSecurityStatus(llm.verdict ?? llm.status) : null;
const statuses: NormalizedSecurityStatus[] = [];
if (staticStatus) statuses.push(staticStatus);
if (vtStatus) statuses.push(vtStatus);
if (llmStatus) statuses.push(llmStatus);
if (statuses.length === 0 && sha256hash) statuses.push("pending");
const status = mergeSecurityStatuses(statuses);
const hasScanResult =
isDefinitiveSecurityStatus(staticStatus) ||
isDefinitiveSecurityStatus(vtStatus) ||
isDefinitiveSecurityStatus(llmStatus);
isDefinitiveSecurityStatus(vtStatus) || isDefinitiveSecurityStatus(llmStatus);
const hasWarnings =
status === "suspicious" || status === "malicious" || hasLlmDimensionWarnings(llm?.dimensions);
const checkedAtCandidates = [staticScan?.checkedAt, vt?.checkedAt, llm?.checkedAt].filter(
const checkedAtCandidates = [vt?.checkedAt, llm?.checkedAt].filter(
(value): value is number => typeof value === "number",
);
const checkedAt = checkedAtCandidates.length > 0 ? Math.max(...checkedAtCandidates) : null;
@@ -334,16 +314,6 @@ function buildSkillSecuritySnapshot(
virustotalUrl: sha256hash ? `https://www.virustotal.com/gui/file/${sha256hash}` : null,
capabilityTags,
scanners: {
static: staticScan
? {
status: staticScan.status,
normalizedStatus: staticStatus ?? "pending",
reasonCodes: staticScan.reasonCodes ?? [],
summary: staticScan.summary ?? null,
engineVersion: staticScan.engineVersion ?? null,
checkedAt: staticScan.checkedAt ?? null,
}
: null,
vt: vt
? {
status: vt.status,
@@ -764,7 +734,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,
},
@@ -999,11 +969,26 @@ async function handleTransferRequest(
if (!toUserHandleRaw) return text("toUserHandle required", 400, headers);
const message = typeof parsed.payload.message === "string" ? parsed.payload.message : undefined;
// Resolve optional publisher handle to a publisher ID
const toPublisherHandleRaw =
typeof parsed.payload.toPublisherHandle === "string"
? parsed.payload.toPublisherHandle.trim()
: "";
let toPublisherId: Id<"publishers"> | undefined;
if (toPublisherHandleRaw) {
const publisher = await ctx.runQuery(internal.publishers.getByHandleInternal, {
handle: toPublisherHandleRaw,
});
if (!publisher) return text("Publisher not found", 404, headers);
toPublisherId = publisher._id;
}
try {
const result = await ctx.runMutation(internal.skillTransfers.requestTransferInternal, {
actorUserId: transferContext.userId,
skillId: transferContext.skill._id,
toUserHandle: toUserHandleRaw,
toPublisherId,
message,
});
return json(result, 200, headers);
@@ -1022,30 +1007,68 @@ async function handleTransferDecision(
const transferContext = await resolveTransferContext(ctx, request, slug, headers);
if (!transferContext.ok) return transferContext.response;
const pendingTransfer =
decision === "cancel"
? await ctx.runQuery(internal.skillTransfers.getPendingTransferBySkillAndFromUserInternal, {
skillId: transferContext.skill._id,
fromUserId: transferContext.userId,
})
: await ctx.runQuery(internal.skillTransfers.getPendingTransferBySkillAndUserInternal, {
skillId: transferContext.skill._id,
toUserId: transferContext.userId,
});
let pendingTransfer;
if (decision === "cancel") {
pendingTransfer = await ctx.runQuery(
internal.skillTransfers.getPendingTransferBySkillAndFromUserInternal,
{ skillId: transferContext.skill._id, fromUserId: transferContext.userId },
);
if (!pendingTransfer) {
// Fallback: allow org admins to cancel transfers initiated by other admins
pendingTransfer = await ctx.runQuery(
internal.skillTransfers.getPendingTransferBySkillInternal,
{ skillId: transferContext.skill._id },
);
}
} else {
// Try user-specific lookup first, then fall back to any pending transfer
// for the skill (allows org admins other than toUserId to accept/reject)
pendingTransfer = await ctx.runQuery(
internal.skillTransfers.getPendingTransferBySkillAndUserInternal,
{ skillId: transferContext.skill._id, toUserId: transferContext.userId },
);
if (!pendingTransfer) {
pendingTransfer = await ctx.runQuery(
internal.skillTransfers.getPendingTransferBySkillInternal,
{ skillId: transferContext.skill._id },
);
}
}
if (!pendingTransfer) return text("No pending transfer found", 404, headers);
const mutation =
decision === "accept"
? internal.skillTransfers.acceptTransferInternal
: decision === "reject"
? internal.skillTransfers.rejectTransferInternal
: internal.skillTransfers.cancelTransferInternal;
try {
const result = await ctx.runMutation(mutation, {
// For accept, resolve optional publisher handle to forward publisherId
let publisherId: Id<"publishers"> | undefined;
if (decision === "accept") {
const parsed = await parseJsonPayload(request, headers);
if (parsed.ok) {
const publisherHandleRaw =
typeof parsed.payload.publisherHandle === "string"
? parsed.payload.publisherHandle.trim()
: "";
if (publisherHandleRaw) {
const publisher = await ctx.runQuery(internal.publishers.getByHandleInternal, {
handle: publisherHandleRaw,
});
if (!publisher) return text("Publisher not found", 404, headers);
publisherId = publisher._id;
}
}
}
const baseArgs = {
actorUserId: transferContext.userId,
transferId: pendingTransfer._id,
});
};
const result = await (decision === "accept"
? ctx.runMutation(internal.skillTransfers.acceptTransferInternal, {
...baseArgs,
publisherId,
})
: decision === "reject"
? ctx.runMutation(internal.skillTransfers.rejectTransferInternal, baseArgs)
: ctx.runMutation(internal.skillTransfers.cancelTransferInternal, baseArgs));
return json(result, 200, headers);
} catch (error) {
return transferErrorToResponse(error, headers);
+6 -1
View File
@@ -16,9 +16,14 @@ export async function transfersGetRouterV1Handler(ctx: ActionCtx, request: Reque
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const transfers =
const skillTransfers =
direction === "incoming"
? await ctx.runQuery(internal.skillTransfers.listIncomingInternal, { userId: auth.userId })
: await ctx.runQuery(internal.skillTransfers.listOutgoingInternal, { userId: auth.userId });
const transfers = skillTransfers.sort(
(a, b) => (b.requestedAt ?? 0) - (a.requestedAt ?? 0),
);
return json({ transfers }, 200, rate.headers);
}
+1 -1
View File
@@ -1,6 +1,6 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
extractWorkflowFilenameFromWorkflowRef,
verifyGitHubActionsTrustedPublishJwt,
+1 -19
View File
@@ -26,7 +26,6 @@ describe("deriveSkillCapabilityTags", () => {
"requires-wallet",
"can-make-purchases",
"can-sign-transactions",
"requires-sensitive-credentials",
]);
});
@@ -40,24 +39,7 @@ describe("deriveSkillCapabilityTags", () => {
fileContents: [],
});
expect(tags).toEqual([
"requires-oauth-token",
"requires-sensitive-credentials",
"posts-externally",
]);
});
it("detects non-oauth API key skills that still need sensitive credentials", () => {
const tags = deriveSkillCapabilityTags({
slug: "minimax-usage",
displayName: "Minimax Usage",
frontmatter: {},
readmeText:
"Create a .env file with MINIMAX_CODING_API_KEY and MINIMAX_GROUP_ID, then send an authorization: Bearer header to the MiniMax endpoint.",
fileContents: [],
});
expect(tags).toEqual(["requires-sensitive-credentials"]);
expect(tags).toEqual(["requires-oauth-token", "posts-externally"]);
});
it("does not treat generic broadcast wording as a crypto transaction signal", () => {
-19
View File
@@ -4,7 +4,6 @@ export const SKILL_CAPABILITY_TAGS = [
"can-make-purchases",
"can-sign-transactions",
"requires-oauth-token",
"requires-sensitive-credentials",
"posts-externally",
] as const;
@@ -97,19 +96,6 @@ const OAUTH_PATTERNS = [
/\btweet\.write\b/,
] satisfies RegExp[];
const SENSITIVE_CREDENTIAL_PATTERNS = [
/api[_ -]?key\b/,
/\baccess token\b/,
/\brefresh token\b/,
/\bbearer token\b/,
/\bsession (?:cookie|cookies)\b/,
/\bauth(?:entication)? (?:cookie|cookies)\b/,
/\bprivate[_ -]?key\b/,
/\bmnemonic\b/,
/\bseed phrase\b/,
/\bsigner\b/,
] satisfies RegExp[];
const EXTERNAL_POST_PATTERNS = [
/\bpost(?: a| this)? tweet\b/,
/\breply to (?:this )?tweet\b/,
@@ -143,7 +129,6 @@ export function deriveSkillCapabilityTags(params: {
const canMakePurchases = matches(text, PURCHASE_PATTERNS);
const canSignTransactions = matches(text, TRANSACTION_PATTERNS);
const requiresOauthToken = matches(text, OAUTH_PATTERNS);
const requiresSensitiveCredentials = matches(text, SENSITIVE_CREDENTIAL_PATTERNS);
const postsExternally = matches(text, EXTERNAL_POST_PATTERNS);
if (isCrypto) tags.add("crypto");
@@ -151,7 +136,6 @@ export function deriveSkillCapabilityTags(params: {
if (canMakePurchases) tags.add("can-make-purchases");
if (canSignTransactions) tags.add("can-sign-transactions");
if (requiresOauthToken) tags.add("requires-oauth-token");
if (requiresSensitiveCredentials) tags.add("requires-sensitive-credentials");
if (postsExternally) tags.add("posts-externally");
if (canSignTransactions || canMakePurchases) {
@@ -160,9 +144,6 @@ export function deriveSkillCapabilityTags(params: {
if (canSignTransactions) {
tags.add("requires-wallet");
}
if (requiresWallet || canSignTransactions || requiresOauthToken) {
tags.add("requires-sensitive-credentials");
}
return SKILL_CAPABILITY_TAGS.filter((tag) => tags.has(tag));
}
-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 -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))) {
+309
View File
@@ -0,0 +1,309 @@
import { describe, expect, it, vi } from "vitest";
import {
TRANSFER_EXPIRY_MS,
isTransferExpired,
normalizeTransferHandle,
validateTransferOwnership,
validateTransferAcceptPermission,
} from "./transfers";
describe("transfers", () => {
it("TRANSFER_EXPIRY_MS is 7 days", () => {
expect(TRANSFER_EXPIRY_MS).toBe(7 * 24 * 60 * 60 * 1000);
});
describe("isTransferExpired", () => {
it("returns true when expiresAt is in the past", () => {
expect(isTransferExpired({ expiresAt: 1000 }, 2000)).toBe(true);
});
it("returns false when expiresAt is in the future", () => {
expect(isTransferExpired({ expiresAt: 3000 }, 2000)).toBe(false);
});
it("returns false when expiresAt equals now", () => {
expect(isTransferExpired({ expiresAt: 2000 }, 2000)).toBe(false);
});
});
describe("normalizeTransferHandle", () => {
it("trims whitespace", () => {
expect(normalizeTransferHandle(" alice ")).toBe("alice");
});
it("strips leading @ and lowercases", () => {
expect(normalizeTransferHandle("@Alice")).toBe("alice");
});
it("strips multiple leading @ signs", () => {
expect(normalizeTransferHandle("@@Bob")).toBe("bob");
});
it("lowercases without @", () => {
expect(normalizeTransferHandle("Charlie")).toBe("charlie");
});
});
describe("validateTransferOwnership", () => {
it("passes for direct owner (personal, no publisher)", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn(),
},
};
await expect(
validateTransferOwnership(ctx as never, {
actorUserId: "users:1" as never,
ownerUserId: "users:1" as never,
ownerPublisherId: undefined,
}),
).resolves.toBeUndefined();
});
it("passes for org admin", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org1",
userId: "users:1",
role: "admin",
}),
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
validateTransferOwnership(ctx as never, {
actorUserId: "users:1" as never,
ownerUserId: "users:99" as never,
ownerPublisherId: "publishers:org1" as never,
}),
).resolves.toBeUndefined();
});
it("passes for org owner role", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org1",
userId: "users:1",
role: "owner",
}),
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
validateTransferOwnership(ctx as never, {
actorUserId: "users:1" as never,
ownerUserId: "users:99" as never,
ownerPublisherId: "publishers:org1" as never,
}),
).resolves.toBeUndefined();
});
it("rejects non-admin org member (publisher role)", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org1",
userId: "users:1",
role: "publisher",
}),
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
validateTransferOwnership(ctx as never, {
actorUserId: "users:1" as never,
ownerUserId: "users:99" as never,
ownerPublisherId: "publishers:org1" as never,
}),
).rejects.toThrow("Forbidden");
});
it("rejects non-owner non-member", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => null,
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
validateTransferOwnership(ctx as never, {
actorUserId: "users:1" as never,
ownerUserId: "users:99" as never,
ownerPublisherId: "publishers:org1" as never,
}),
).rejects.toThrow("Forbidden");
});
it("rejects personal item when actor is not the owner", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn(),
},
};
await expect(
validateTransferOwnership(ctx as never, {
actorUserId: "users:2" as never,
ownerUserId: "users:1" as never,
ownerPublisherId: undefined,
}),
).rejects.toThrow("Forbidden");
});
});
describe("validateTransferAcceptPermission", () => {
it("passes for personal target when actor is the target user", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn(),
},
};
await expect(
validateTransferAcceptPermission(ctx as never, {
actorUserId: "users:1" as never,
toUserId: "users:1" as never,
toPublisherId: undefined,
}),
).resolves.toBeUndefined();
});
it("rejects personal target when actor is not the target user", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn(),
},
};
await expect(
validateTransferAcceptPermission(ctx as never, {
actorUserId: "users:2" as never,
toUserId: "users:1" as never,
toPublisherId: undefined,
}),
).rejects.toThrow("No pending transfer found");
});
it("passes for org target when actor is admin", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
get: vi.fn(async () => ({
_id: "publishers:org1",
kind: "org",
handle: "myorg",
})),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org1",
userId: "users:1",
role: "admin",
}),
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
validateTransferAcceptPermission(ctx as never, {
actorUserId: "users:1" as never,
toUserId: "users:99" as never,
toPublisherId: "publishers:org1" as never,
}),
).resolves.toBeUndefined();
});
it("rejects org target when actor is not admin/owner", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
get: vi.fn(async () => ({
_id: "publishers:org1",
kind: "org",
handle: "myorg",
})),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org1",
userId: "users:1",
role: "publisher",
}),
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
validateTransferAcceptPermission(ctx as never, {
actorUserId: "users:1" as never,
toUserId: "users:99" as never,
toPublisherId: "publishers:org1" as never,
}),
).rejects.toThrow("No pending transfer found");
});
});
});
+91
View File
@@ -0,0 +1,91 @@
import type { Doc, Id } from "../_generated/dataModel";
import type { QueryCtx, MutationCtx } from "../_generated/server";
import { getPublisherMembership, isPublisherActive, isPublisherRoleAllowed } from "./publishers";
type DbCtx = Pick<QueryCtx | MutationCtx, "db">;
/** 7 days in milliseconds */
export const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
/** Returns true if the transfer has expired (expiresAt is strictly less than now). */
export function isTransferExpired(transfer: { expiresAt: number }, now: number): boolean {
return transfer.expiresAt < now;
}
/**
* Trims whitespace, strips leading `@` characters, and lowercases:
* `"@Alice"` -> `"alice"`, `"@@Bob"` -> `"bob"`
*/
export function normalizeTransferHandle(value: string): string {
return value.trim().replace(/^@+/, "").toLowerCase();
}
/**
* Validates that `actorUserId` has permission to initiate a transfer.
*
* - If the item is personally owned (`ownerPublisherId` is null/undefined):
* actor must be the `ownerUserId`.
* - If the item is org-owned: actor must be an admin or owner in that org's
* publisherMembers.
*
* @throws {Error} "Forbidden" on failure
*/
export async function validateTransferOwnership(
ctx: DbCtx,
params: {
actorUserId: Id<"users">;
ownerUserId: Id<"users">;
ownerPublisherId?: Id<"publishers"> | null;
},
): Promise<void> {
if (!params.ownerPublisherId) {
// Personally owned — actor must be the owner
if (params.actorUserId !== params.ownerUserId) {
throw new Error("Forbidden");
}
return;
}
// Org-owned — actor must be admin or owner in the org
const membership = await getPublisherMembership(ctx, params.ownerPublisherId, params.actorUserId);
if (!membership || !isPublisherRoleAllowed(membership.role, ["admin"])) {
throw new Error("Forbidden");
}
}
/**
* Validates that `actorUserId` can accept a transfer.
*
* - If `toPublisherId` is null (personal target): actor must be `toUserId`.
* - If `toPublisherId` is set (org target): actor must be admin/owner of that org.
*
* @throws {Error} "No pending transfer found" on failure
*/
export async function validateTransferAcceptPermission(
ctx: DbCtx,
params: {
actorUserId: Id<"users">;
toUserId?: Id<"users"> | null;
toPublisherId?: Id<"publishers"> | null;
},
): Promise<void> {
if (!params.toPublisherId) {
// Personal target — actor must be the target user
if (params.actorUserId !== params.toUserId) {
throw new Error("No pending transfer found");
}
return;
}
// Org target — publisher must be active and actor must be admin or owner
const db = (ctx as { db: { get: (id: Id<"publishers">) => Promise<Doc<"publishers"> | null> } })
.db;
const publisher = await db.get(params.toPublisherId);
if (!isPublisherActive(publisher)) {
throw new Error("Publisher not found");
}
const membership = await getPublisherMembership(ctx, params.toPublisherId, params.actorUserId);
if (!membership || !isPublisherRoleAllowed(membership.role, ["admin"])) {
throw new Error("No pending transfer found");
}
}
-67
View File
@@ -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,
});
}
}
-62
View File
@@ -7,10 +7,6 @@ vi.mock("./_generated/api", () => ({
getSkillBackfillPageInternal: Symbol("getSkillBackfillPageInternal"),
applySkillBackfillPatchInternal: Symbol("applySkillBackfillPatchInternal"),
backfillSkillSummariesInternal: Symbol("backfillSkillSummariesInternal"),
getUserStatsBackfillPageInternal: Symbol("getUserStatsBackfillPageInternal"),
getUserOwnedSkillsBackfillPageInternal: Symbol("getUserOwnedSkillsBackfillPageInternal"),
applyUserStatsBackfillPatchInternal: Symbol("applyUserStatsBackfillPatchInternal"),
backfillUserStatsInternal: Symbol("backfillUserStatsInternal"),
getSkillFingerprintBackfillPageInternal: Symbol("getSkillFingerprintBackfillPageInternal"),
applySkillFingerprintBackfillPatchInternal: Symbol(
"applySkillFingerprintBackfillPatchInternal",
@@ -40,7 +36,6 @@ const {
backfillLatestVersionSummaryInternal,
backfillSkillFingerprintsInternalHandler,
backfillSkillSummariesInternalHandler,
backfillUserStatsInternalHandler,
cleanupEmptySkillsInternalHandler,
nominateEmptySkillSpammersInternalHandler,
upsertSkillBadgeRecordInternal,
@@ -264,63 +259,6 @@ describe("maintenance backfill", () => {
});
expect(runAfter).not.toHaveBeenCalled();
});
it("backfills denormalized user hover stats from indexed owner pages", async () => {
const runQuery = vi
.fn()
.mockResolvedValueOnce({
items: [{ _id: "users:1" }],
cursor: null,
isDone: true,
})
.mockResolvedValueOnce({
items: [
{ stats: { stars: 4, downloads: 30 }, softDeletedAt: undefined },
{ stats: { stars: 2, downloads: 10 }, softDeletedAt: 123 },
{ stats: { stars: 1, downloads: 5 }, softDeletedAt: undefined },
],
cursor: null,
isDone: true,
});
const runMutation = vi.fn().mockResolvedValue({ ok: true });
const result = await backfillUserStatsInternalHandler(
{ runQuery, runMutation } as never,
{ batchSize: 10, skillBatchSize: 50, maxBatches: 1 },
);
expect(result).toEqual({
ok: true,
stats: {
usersScanned: 1,
usersPatched: 1,
},
isDone: true,
cursor: null,
});
expect(runQuery).toHaveBeenNthCalledWith(1, internal.maintenance.getUserStatsBackfillPageInternal, {
cursor: undefined,
batchSize: 10,
});
expect(runQuery).toHaveBeenNthCalledWith(
2,
internal.maintenance.getUserOwnedSkillsBackfillPageInternal,
{
ownerUserId: "users:1",
cursor: undefined,
batchSize: 50,
},
);
expect(runMutation).toHaveBeenCalledWith(
internal.maintenance.applyUserStatsBackfillPatchInternal,
{
userId: "users:1",
publishedSkills: 2,
totalStars: 5,
totalDownloads: 35,
},
);
});
});
describe("maintenance badge denormalization", () => {
-167
View File
@@ -36,11 +36,6 @@ type BackfillStats = {
missingStorageBlob: number;
};
type UserStatsBackfillStats = {
usersScanned: number;
usersPatched: number;
};
type BackfillPageItem =
| {
kind: "ok";
@@ -62,18 +57,6 @@ type BackfillPageResult = {
isDone: boolean;
};
type UserStatsBackfillPageResult = {
items: Array<Pick<Doc<"users">, "_id">>;
cursor: string | null;
isDone: boolean;
};
type UserOwnedSkillsBackfillPageResult = {
items: Array<Pick<Doc<"skills">, "stats" | "softDeletedAt">>;
cursor: string | null;
isDone: boolean;
};
export const getSkillBackfillPageInternal = internalQuery({
args: {
cursor: v.optional(v.string()),
@@ -153,65 +136,6 @@ export const applySkillBackfillPatchInternal = internalMutation({
},
});
export const getUserStatsBackfillPageInternal = internalQuery({
args: {
cursor: v.optional(v.string()),
batchSize: v.optional(v.number()),
},
handler: async (ctx, args): Promise<UserStatsBackfillPageResult> => {
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
const { page, isDone, continueCursor } = await ctx.db
.query("users")
.order("asc")
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
return {
items: page.map((user) => ({ _id: user._id })),
cursor: continueCursor,
isDone,
};
},
});
export const getUserOwnedSkillsBackfillPageInternal = internalQuery({
args: {
ownerUserId: v.id("users"),
cursor: v.optional(v.string()),
batchSize: v.optional(v.number()),
},
handler: async (ctx, args): Promise<UserOwnedSkillsBackfillPageResult> => {
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
const { page, isDone, continueCursor } = await ctx.db
.query("skills")
.withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId))
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
return {
items: page.map((skill) => ({
stats: skill.stats,
softDeletedAt: skill.softDeletedAt,
})),
cursor: continueCursor,
isDone,
};
},
});
export const applyUserStatsBackfillPatchInternal = internalMutation({
args: {
userId: v.id("users"),
publishedSkills: v.number(),
totalStars: v.number(),
totalDownloads: v.number(),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.userId, {
publishedSkills: args.publishedSkills,
totalStars: args.totalStars,
totalDownloads: args.totalDownloads,
});
return { ok: true as const };
},
});
export type BackfillActionArgs = {
dryRun?: boolean;
batchSize?: number;
@@ -227,20 +151,6 @@ export type BackfillActionResult = {
cursor: string | null;
};
export type UserStatsBackfillActionArgs = {
batchSize?: number;
skillBatchSize?: number;
maxBatches?: number;
cursor?: string;
};
export type UserStatsBackfillActionResult = {
ok: true;
stats: UserStatsBackfillStats;
isDone: boolean;
cursor: string | null;
};
export async function backfillSkillSummariesInternalHandler(
ctx: ActionCtx,
args: BackfillActionArgs,
@@ -336,73 +246,6 @@ export async function backfillSkillSummariesInternalHandler(
return { ok: true as const, stats: totals, isDone, cursor };
}
export async function backfillUserStatsInternalHandler(
ctx: ActionCtx,
args: UserStatsBackfillActionArgs,
): Promise<UserStatsBackfillActionResult> {
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
const skillBatchSize = clampInt(args.skillBatchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES);
const totals: UserStatsBackfillStats = {
usersScanned: 0,
usersPatched: 0,
};
let cursor: string | null = args.cursor ?? null;
let isDone = false;
for (let i = 0; i < maxBatches; i++) {
const page = (await ctx.runQuery(internal.maintenance.getUserStatsBackfillPageInternal, {
cursor: cursor ?? undefined,
batchSize,
})) as UserStatsBackfillPageResult;
cursor = page.cursor;
isDone = page.isDone;
for (const user of page.items) {
totals.usersScanned++;
let ownedSkillsCursor: string | null = null;
let userPublishedSkills = 0;
let userTotalStars = 0;
let userTotalDownloads = 0;
while (true) {
const skillPage = (await ctx.runQuery(
internal.maintenance.getUserOwnedSkillsBackfillPageInternal,
{
ownerUserId: user._id,
cursor: ownedSkillsCursor ?? undefined,
batchSize: skillBatchSize,
},
)) as UserOwnedSkillsBackfillPageResult;
for (const skill of skillPage.items) {
if (skill.softDeletedAt) continue;
userPublishedSkills += 1;
userTotalStars += skill.stats?.stars ?? 0;
userTotalDownloads += skill.stats?.downloads ?? 0;
}
if (skillPage.isDone) break;
ownedSkillsCursor = skillPage.cursor;
}
await ctx.runMutation(internal.maintenance.applyUserStatsBackfillPatchInternal, {
userId: user._id,
publishedSkills: userPublishedSkills,
totalStars: userTotalStars,
totalDownloads: userTotalDownloads,
});
totals.usersPatched++;
}
if (isDone) break;
}
return { ok: true as const, stats: totals, isDone, cursor };
}
export const backfillSkillSummariesInternal = internalAction({
args: {
dryRun: v.optional(v.boolean()),
@@ -414,16 +257,6 @@ export const backfillSkillSummariesInternal = internalAction({
handler: backfillSkillSummariesInternalHandler,
});
export const backfillUserStatsInternal = internalAction({
args: {
batchSize: v.optional(v.number()),
skillBatchSize: v.optional(v.number()),
maxBatches: v.optional(v.number()),
cursor: v.optional(v.string()),
},
handler: backfillUserStatsInternalHandler,
});
export const backfillSkillSummaries: ReturnType<typeof action> = action({
args: {
dryRun: v.optional(v.boolean()),
-2
View File
@@ -221,7 +221,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 +326,6 @@ function toPublicPackage(
capabilities: pkg.capabilities,
verification: pkg.verification,
scanStatus: pkg.scanStatus,
stats: pkg.stats,
createdAt: pkg.createdAt,
updatedAt: pkg.updatedAt,
};
-139
View File
@@ -5,7 +5,6 @@ import {
listMine,
migrateLegacyPublisherHandleToOrgInternal,
removeMember,
updateProfile,
} from "./publishers";
vi.mock("@convex-dev/auth/server", () => ({
@@ -53,15 +52,6 @@ const listMineHandler = (
listMine as unknown as WrappedHandler<Record<string, never>, Array<unknown>>
)._handler;
const updateProfileHandler = (
updateProfile as unknown as WrappedHandler<{
publisherId: string;
displayName: string;
bio?: string;
image?: string;
}>
)._handler;
describe("publishers membership controls", () => {
it("prevents admins from promoting members to owner", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
@@ -381,135 +371,6 @@ describe("publishers membership controls", () => {
}),
);
});
it("lets org admins update org profile fields", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
const patch = vi.fn(async () => {});
const insert = vi.fn(async () => "auditLogs:1");
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:admin") return { _id: id };
if (id === "publishers:org") {
return {
_id: id,
kind: "org",
handle: "shopify",
displayName: "Shopify",
image: undefined,
bio: undefined,
};
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue({
_id: "publisherMembers:admin",
publisherId: "publishers:org",
userId: "users:admin",
role: "admin",
}),
})),
};
}
throw new Error(`unexpected table ${table}`);
}),
patch,
insert,
delete: vi.fn(),
replace: vi.fn(),
normalizeId: vi.fn(),
},
};
await expect(
updateProfileHandler(
ctx as never,
{
publisherId: "publishers:org",
displayName: "Shopify",
bio: "Commerce platform",
image: "https://cdn.example.com/shopify.png",
} as never,
),
).resolves.toEqual({
ok: true,
publisher: expect.objectContaining({
_id: "publishers:org",
displayName: "Shopify",
}),
});
expect(patch).toHaveBeenCalledWith(
"publishers:org",
expect.objectContaining({
displayName: "Shopify",
bio: "Commerce platform",
image: "https://cdn.example.com/shopify.png",
}),
);
expect(insert).toHaveBeenCalledWith(
"auditLogs",
expect.objectContaining({
action: "publisher.profile.update",
targetId: "publishers:org",
}),
);
});
it("rejects invalid org profile image URLs", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:admin") return { _id: id };
if (id === "publishers:org") {
return {
_id: id,
kind: "org",
handle: "shopify",
displayName: "Shopify",
};
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue({
_id: "publisherMembers:admin",
publisherId: "publishers:org",
userId: "users:admin",
role: "admin",
}),
})),
};
}
throw new Error(`unexpected table ${table}`);
}),
patch: vi.fn(),
insert: vi.fn(),
delete: vi.fn(),
replace: vi.fn(),
normalizeId: vi.fn(),
},
};
await expect(
updateProfileHandler(
ctx as never,
{
publisherId: "publishers:org",
displayName: "Shopify",
image: "not-a-url",
} as never,
),
).rejects.toThrow("Image must be a valid URL");
});
});
describe("publisher bootstrap", () => {
-64
View File
@@ -539,70 +539,6 @@ export const createOrg = mutation({
},
});
export const updateProfile = mutation({
args: {
publisherId: v.id("publishers"),
displayName: v.string(),
bio: v.optional(v.string()),
image: v.optional(v.string()),
},
handler: async (ctx, args) => {
const { userId } = await requireUser(ctx);
const publisher = await ctx.db.get(args.publisherId);
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) {
throw new ConvexError("Publisher not found");
}
if (publisher.kind !== "org") {
throw new ConvexError("Only org publishers can be updated here");
}
const membership = await getPublisherMembership(ctx, publisher._id, userId);
if (!membership || !isPublisherRoleAllowed(membership.role, ["admin"])) {
throw new ConvexError("Forbidden");
}
const displayName = args.displayName.trim() || publisher.handle;
const bio = args.bio?.trim() || undefined;
const image = args.image?.trim() || undefined;
if (image) {
let parsed: URL;
try {
parsed = new URL(image);
} catch {
throw new ConvexError("Image must be a valid URL");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new ConvexError("Image must use http or https");
}
}
const now = Date.now();
await ctx.db.patch(publisher._id, {
displayName,
bio,
image,
updatedAt: now,
});
await ctx.db.insert("auditLogs", {
actorUserId: userId,
action: "publisher.profile.update",
targetType: "publisher",
targetId: publisher._id,
metadata: {
displayName,
bio,
image,
},
createdAt: now,
});
return {
ok: true as const,
publisher: toPublicPublisher(await ctx.db.get(publisher._id)),
};
},
});
export const migrateLegacyPublisherHandleToOrg = mutation({
args: {
handle: v.string(),
+6 -8
View File
@@ -28,9 +28,6 @@ const users = defineTable({
githubFetchedAt: v.optional(v.number()),
githubProfileSyncedAt: v.optional(v.number()),
trustedPublisher: v.optional(v.boolean()),
publishedSkills: v.optional(v.number()),
totalStars: v.optional(v.number()),
totalDownloads: v.optional(v.number()),
personalPublisherId: v.optional(v.id("publishers")),
requiresModerationAt: v.optional(v.number()),
requiresModerationReason: v.optional(v.string()),
@@ -43,8 +40,7 @@ const users = defineTable({
})
.index("email", ["email"])
.index("phone", ["phone"])
.index("handle", ["handle"])
.index("by_active_handle", ["deletedAt", "deactivatedAt", "handle"]);
.index("handle", ["handle"]);
const publishers = defineTable({
kind: v.union(v.literal("user"), v.literal("org")),
@@ -388,8 +384,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"),
@@ -1291,7 +1286,9 @@ const userSkillRootInstalls = defineTable({
const skillOwnershipTransfers = defineTable({
skillId: v.id("skills"),
fromUserId: v.id("users"),
toUserId: v.id("users"),
toUserId: v.optional(v.id("users")),
fromPublisherId: v.optional(v.id("publishers")),
toPublisherId: v.optional(v.id("publishers")),
status: v.union(
v.literal("pending"),
v.literal("accepted"),
@@ -1308,6 +1305,7 @@ const skillOwnershipTransfers = defineTable({
.index("by_from_user", ["fromUserId"])
.index("by_to_user", ["toUserId"])
.index("by_to_user_status", ["toUserId", "status"])
.index("by_to_publisher_status", ["toPublisherId", "status"])
.index("by_from_user_status", ["fromUserId", "status"])
.index("by_skill_status", ["skillId", "status"]);
-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
+75
View File
@@ -389,4 +389,79 @@ describe("skillTransfers", () => {
expect.objectContaining({ ownerUserId: "users:2" }),
);
});
it("requestTransferInternal allows org admin to request transfer", async () => {
// Org admin (users:2) can request transfer of a skill owned by org (publishers:org1)
// even though ownerUserId is users:1
const insert = vi.fn(async (table: string) => {
if (table === "skillOwnershipTransfers") return "skillOwnershipTransfers:new";
return "auditLogs:1";
});
const result = (await requestTransferInternalHandler(
{
db: {
normalizeId: vi.fn(),
get: vi.fn(async (id: string) => {
if (id === "users:2") return { _id: "users:2", handle: "orgadmin" };
if (id === "skills:1") {
return {
_id: "skills:1",
slug: "demo",
displayName: "Demo",
ownerUserId: "users:1",
ownerPublisherId: "publishers:org1",
};
}
if (id === "publishers:org1") {
return { _id: "publishers:org1", kind: "org", handle: "myorg" };
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "users") {
return {
withIndex: () => ({
unique: async () => ({
_id: "users:3",
handle: "recipient",
displayName: "Recipient",
}),
}),
};
}
if (table === "skillOwnershipTransfers") {
return { withIndex: () => ({ collect: async () => [] }) };
}
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org1",
userId: "users:2",
role: "admin",
}),
}),
};
}
if (table === "publishers") {
return { withIndex: () => ({ unique: async () => null }) };
}
throw new Error(`unexpected table ${table}`);
}),
patch: vi.fn(async () => {}),
insert,
},
} as never,
{
actorUserId: "users:2",
skillId: "skills:1",
toUserHandle: "@recipient",
} as never,
)) as { ok: boolean; transferId: string };
expect(result.ok).toBe(true);
expect(result.transferId).toBe("skillOwnershipTransfers:new");
});
});
+174 -37
View File
@@ -5,18 +5,16 @@ import {
ensurePersonalPublisherForUser,
getActiveUserByHandleOrPersonalPublisher,
} from "./lib/publishers";
const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
import {
TRANSFER_EXPIRY_MS,
isTransferExpired,
normalizeTransferHandle,
validateTransferOwnership,
validateTransferAcceptPermission,
} from "./lib/transfers";
type TransferDoc = Doc<"skillOwnershipTransfers">;
function normalizeHandle(value: string) {
return value.trim().replace(/^@+/, "").toLowerCase();
}
function isExpired(transfer: TransferDoc, now: number) {
return transfer.expiresAt < now;
}
async function requireActiveUserById(ctx: unknown, userId: Id<"users">) {
const db = (ctx as { db: { get: (id: Id<"users">) => Promise<Doc<"users"> | null> } }).db;
const user = await db.get(userId);
@@ -53,7 +51,7 @@ async function getActivePendingTransferForSkill(ctx: unknown, skillId: Id<"skill
let active: TransferDoc | null = null;
for (const transfer of transfers) {
if (isExpired(transfer, now)) {
if (isTransferExpired(transfer, now)) {
await db.patch(transfer._id, { status: "expired", respondedAt: now });
continue;
}
@@ -83,14 +81,26 @@ async function validatePendingTransferForActor(
const transfer = await db.get(params.transferId);
if (!transfer) throw new Error("Transfer not found");
if (params.role === "recipient" && transfer.toUserId !== params.actorUserId) {
if (
params.role === "recipient" &&
transfer.toUserId &&
transfer.toUserId !== params.actorUserId &&
!transfer.toPublisherId
) {
// For org-targeted transfers (toPublisherId is set), skip this check —
// validateTransferAcceptPermission handles org membership validation separately
throw new Error("No pending transfer found");
}
if (params.role === "sender" && transfer.fromUserId !== params.actorUserId) {
throw new Error("No pending transfer found");
if (params.role === "sender") {
if (!transfer.fromPublisherId && transfer.fromUserId !== params.actorUserId) {
// Personal transfer: actor must be the original sender
throw new Error("No pending transfer found");
}
// Org-owned transfer: actor's org membership is verified by the caller
// (e.g. cancelTransferInternal calls validateTransferOwnership after this)
}
if (transfer.status !== "pending") throw new Error("No pending transfer found");
if (isExpired(transfer, params.now)) {
if (isTransferExpired(transfer, params.now)) {
await db.patch(transfer._id, { status: "expired", respondedAt: params.now });
throw new Error("Transfer has expired");
}
@@ -102,6 +112,7 @@ export const requestTransferInternal = internalMutation({
actorUserId: v.id("users"),
skillId: v.id("skills"),
toUserHandle: v.string(),
toPublisherId: v.optional(v.id("publishers")),
message: v.optional(v.string()),
},
handler: async (ctx, args) => {
@@ -110,14 +121,28 @@ export const requestTransferInternal = internalMutation({
const skill = await ctx.db.get(args.skillId);
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
if (skill.ownerUserId !== args.actorUserId) throw new Error("Forbidden");
const toHandle = normalizeHandle(args.toUserHandle);
await validateTransferOwnership(ctx, {
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId,
actorUserId: args.actorUserId,
});
const toHandle = normalizeTransferHandle(args.toUserHandle);
if (!toHandle) throw new Error("toUserHandle required");
const toUser = await getActiveUserByHandleOrPersonalPublisher(ctx, toHandle);
if (!toUser) throw new Error("User not found");
if (toUser._id === args.actorUserId) throw new Error("Cannot transfer to yourself");
if (toUser._id === args.actorUserId && !args.toPublisherId) {
throw new Error("Cannot transfer to yourself");
}
if (args.toPublisherId) {
const toPublisher = await ctx.db.get(args.toPublisherId);
if (!toPublisher || toPublisher.deletedAt || toPublisher.deactivatedAt) {
throw new Error("Target publisher not found");
}
}
const activePending = await getActivePendingTransferForSkill(ctx, args.skillId, now);
if (activePending) throw new Error("A transfer is already pending for this skill");
@@ -128,6 +153,8 @@ export const requestTransferInternal = internalMutation({
skillId: skill._id,
fromUserId: args.actorUserId,
toUserId: toUser._id,
fromPublisherId: skill.ownerPublisherId,
toPublisherId: args.toPublisherId,
status: "pending",
message: message || undefined,
requestedAt: now,
@@ -155,6 +182,7 @@ export const acceptTransferInternal = internalMutation({
args: {
actorUserId: v.id("users"),
transferId: v.id("skillOwnershipTransfers"),
publisherId: v.optional(v.id("publishers")),
},
handler: async (ctx, args) => {
const now = Date.now();
@@ -167,19 +195,45 @@ export const acceptTransferInternal = internalMutation({
now,
});
await validateTransferAcceptPermission(ctx, {
actorUserId: args.actorUserId,
toUserId: transfer.toUserId ?? undefined,
toPublisherId: transfer.toPublisherId,
});
const skill = await ctx.db.get(transfer.skillId);
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
if (skill.ownerUserId !== transfer.fromUserId) {
const ownerChanged = transfer.fromPublisherId
? skill.ownerPublisherId !== transfer.fromPublisherId
: skill.ownerUserId !== transfer.fromUserId;
if (ownerChanged) {
await ctx.db.patch(transfer._id, { status: "cancelled", respondedAt: now });
throw new Error("Transfer is no longer valid");
}
const newPublisher = await ensurePersonalPublisherForUser(ctx, newOwner);
if (!newPublisher) throw new Error("Failed to resolve publisher for new owner");
// Determine target publisher: sender's choice > recipient override > personal
// When the sender specified a target publisher, honor it unconditionally
let targetPublisherId: Id<"publishers">;
if (transfer.toPublisherId) {
targetPublisherId = transfer.toPublisherId;
} else if (args.publisherId) {
await validateTransferAcceptPermission(ctx, {
actorUserId: args.actorUserId,
toPublisherId: args.publisherId,
});
targetPublisherId = args.publisherId;
} else {
const newPublisher = await ensurePersonalPublisherForUser(ctx, newOwner);
if (!newPublisher) throw new Error("Failed to resolve publisher for new owner");
targetPublisherId = newPublisher._id;
}
// For org-targeted transfers, ownerUserId is set to whichever admin accepts,
// not necessarily the original toUserId. The toUserHandle on the request just
// routes the transfer — actual ownership reflects who acted on it.
await ctx.db.patch(skill._id, {
ownerUserId: args.actorUserId,
ownerPublisherId: newPublisher._id,
ownerPublisherId: targetPublisherId,
updatedAt: now,
});
@@ -190,12 +244,16 @@ export const acceptTransferInternal = internalMutation({
for (const alias of aliases) {
await ctx.db.patch(alias._id, {
ownerUserId: args.actorUserId,
ownerPublisherId: newPublisher._id,
ownerPublisherId: targetPublisherId,
updatedAt: now,
});
}
await ctx.db.patch(transfer._id, { status: "accepted", respondedAt: now });
await ctx.db.patch(transfer._id, {
status: "accepted",
respondedAt: now,
toUserId: args.actorUserId,
});
await ctx.db.insert("auditLogs", {
actorUserId: args.actorUserId,
@@ -229,6 +287,12 @@ export const rejectTransferInternal = internalMutation({
now,
});
await validateTransferAcceptPermission(ctx, {
actorUserId: args.actorUserId,
toUserId: transfer.toUserId ?? undefined,
toPublisherId: transfer.toPublisherId,
});
await ctx.db.patch(transfer._id, { status: "rejected", respondedAt: now });
await ctx.db.insert("auditLogs", {
actorUserId: args.actorUserId,
@@ -259,6 +323,15 @@ export const cancelTransferInternal = internalMutation({
now,
});
// For org-owned transfers, always verify actor still has admin/owner role
if (transfer.fromPublisherId) {
await validateTransferOwnership(ctx, {
ownerUserId: transfer.fromUserId,
ownerPublisherId: transfer.fromPublisherId,
actorUserId: args.actorUserId,
});
}
await ctx.db.patch(transfer._id, { status: "cancelled", respondedAt: now });
await ctx.db.insert("auditLogs", {
actorUserId: args.actorUserId,
@@ -279,28 +352,63 @@ export const listIncomingInternal = internalQuery({
const now = Date.now();
await requireActiveUserById(ctx, args.userId);
const transfers = await ctx.db
// Query transfers directed at this user personally
const userTransfers = await ctx.db
.query("skillOwnershipTransfers")
.withIndex("by_to_user_status", (q) => q.eq("toUserId", args.userId).eq("status", "pending"))
.collect();
// Query transfers directed at orgs where this user is an admin or owner
const memberships = await ctx.db
.query("publisherMembers")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
const adminPublisherIds = memberships
.filter((m) => m.role === "owner" || m.role === "admin")
.map((m) => m.publisherId);
const orgTransferArrays = await Promise.all(
adminPublisherIds.map((publisherId) =>
ctx.db
.query("skillOwnershipTransfers")
.withIndex("by_to_publisher_status", (q) =>
q.eq("toPublisherId", publisherId).eq("status", "pending"),
)
.collect(),
),
);
const orgTransfers = orgTransferArrays.flat();
// Merge and deduplicate by transfer ID
const seen = new Set<string>();
const allTransfers: TransferDoc[] = [];
for (const t of [...userTransfers, ...orgTransfers]) {
if (!seen.has(t._id)) {
seen.add(t._id);
allTransfers.push(t);
}
}
const results: Array<{
type: "skill";
_id: Id<"skillOwnershipTransfers">;
skill: { _id: Id<"skills">; slug: string; displayName: string };
fromUser: { _id: Id<"users">; handle: string | null; displayName: string | null };
toPublisherId?: Id<"publishers">;
message: string | undefined;
requestedAt: number;
expiresAt: number;
}> = [];
for (const transfer of transfers) {
if (isExpired(transfer, now)) continue;
for (const transfer of allTransfers) {
if (isTransferExpired(transfer, now)) continue;
const skill = await ctx.db.get(transfer.skillId);
if (!skill || skill.softDeletedAt) continue;
const fromUser = await ctx.db.get(transfer.fromUserId);
if (!fromUser || fromUser.deletedAt || fromUser.deactivatedAt) continue;
results.push({
type: "skill" as const,
_id: transfer._id,
skill: { _id: skill._id, slug: skill.slug, displayName: skill.displayName },
fromUser: {
@@ -308,6 +416,7 @@ export const listIncomingInternal = internalQuery({
handle: fromUser.handle ?? null,
displayName: fromUser.displayName ?? null,
},
toPublisherId: transfer.toPublisherId ?? undefined,
message: transfer.message,
requestedAt: transfer.requestedAt,
expiresAt: transfer.expiresAt,
@@ -332,29 +441,41 @@ export const listOutgoingInternal = internalQuery({
.collect();
const results: Array<{
type: "skill";
_id: Id<"skillOwnershipTransfers">;
skill: { _id: Id<"skills">; slug: string; displayName: string };
toUser: { _id: Id<"users">; handle: string | null; displayName: string | null };
toUser?: { _id: Id<"users">; handle: string | null; displayName: string | null };
toPublisherId?: Id<"publishers">;
message: string | undefined;
requestedAt: number;
expiresAt: number;
}> = [];
for (const transfer of transfers) {
if (isExpired(transfer, now)) continue;
if (isTransferExpired(transfer, now)) continue;
const skill = await ctx.db.get(transfer.skillId);
if (!skill || skill.softDeletedAt) continue;
const toUser = await ctx.db.get(transfer.toUserId);
if (!toUser || toUser.deletedAt || toUser.deactivatedAt) continue;
let toUser:
| { _id: Id<"users">; handle: string | null; displayName: string | null }
| undefined;
if (transfer.toUserId) {
const tu = await ctx.db.get(transfer.toUserId);
if (tu && !tu.deletedAt && !tu.deactivatedAt) {
toUser = {
_id: tu._id,
handle: tu.handle ?? null,
displayName: tu.displayName ?? null,
};
}
}
results.push({
type: "skill" as const,
_id: transfer._id,
skill: { _id: skill._id, slug: skill.slug, displayName: skill.displayName },
toUser: {
_id: toUser._id,
handle: toUser.handle ?? null,
displayName: toUser.displayName ?? null,
},
toUser,
toPublisherId: transfer.toPublisherId ?? undefined,
message: transfer.message,
requestedAt: transfer.requestedAt,
expiresAt: transfer.expiresAt,
@@ -365,6 +486,22 @@ export const listOutgoingInternal = internalQuery({
},
});
export const getPendingTransferBySkillInternal = internalQuery({
args: {
skillId: v.id("skills"),
},
handler: async (ctx, args) => {
const now = Date.now();
const transfer = await ctx.db
.query("skillOwnershipTransfers")
.withIndex("by_skill_status", (q) => q.eq("skillId", args.skillId).eq("status", "pending"))
.first();
if (!transfer || isTransferExpired(transfer, now)) return null;
return transfer;
},
});
export const getPendingTransferBySkillAndUserInternal = internalQuery({
args: {
skillId: v.id("skills"),
@@ -378,7 +515,7 @@ export const getPendingTransferBySkillAndUserInternal = internalQuery({
.filter((q) => q.eq(q.field("toUserId"), args.toUserId))
.first();
if (!transfer || isExpired(transfer, now)) return null;
if (!transfer || isTransferExpired(transfer, now)) return null;
return transfer;
},
});
@@ -396,7 +533,7 @@ export const getPendingTransferBySkillAndFromUserInternal = internalQuery({
.filter((q) => q.eq(q.field("fromUserId"), args.fromUserId))
.first();
if (!transfer || isExpired(transfer, now)) return null;
if (!transfer || isTransferExpired(transfer, now)) return null;
return transfer;
},
});
-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",
}),
]);
});
});
+1 -10
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";
@@ -84,7 +83,6 @@ import {
extractDigestFields,
upsertSkillSearchDigest,
} from "./lib/skillSearchDigest";
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
import schema from "./schema";
export { publishVersionForUser } from "./lib/skillPublish";
@@ -759,7 +757,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 +1229,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 +2528,6 @@ export const report = mutation({
const nextSkill = { ...skill, ...updates };
await ctx.db.patch(skill._id, updates);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
if (shouldAutoHide) {
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, now);
@@ -4258,7 +4254,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 +4365,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 +5304,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 +5342,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 +6422,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));
},
-303
View File
@@ -1,303 +0,0 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
// Mock the Convex function wrappers so that importing statsMaintenance.ts does
// not attempt to load the Convex runtime (convex/server) in the Node test env.
vi.mock("./functions", () => ({
internalMutation: (def: { handler: unknown }) => def,
internalQuery: (def: { handler: unknown }) => def,
internalAction: (def: { handler: unknown }) => def,
}));
vi.mock("./_generated/api", () => ({
internal: {
statsMaintenance: {
backfillSkillStatFieldsInternal: Symbol("backfillSkillStatFieldsInternal"),
getSkillStatBackfillStateInternal: Symbol("getSkillStatBackfillStateInternal"),
setSkillStatBackfillStateInternal: Symbol("setSkillStatBackfillStateInternal"),
reconcileSkillStarCounts: Symbol("reconcileSkillStarCounts"),
},
},
}));
const { __test, reconcileSkillStarCountsHandler } = await import("./statsMaintenance");
const { buildSkillStatPatch } = __test;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Build a minimal skill doc for testing. Only the stat-related fields are
* required; everything else is left as `undefined` / cast via `as never`.
*/
function makeSkill(overrides: {
statsDownloads?: number;
statsStars?: number;
statsInstallsCurrent?: number;
statsInstallsAllTime?: number;
stats: {
downloads: number;
stars: number;
installsCurrent?: number;
installsAllTime?: number;
comments: number;
};
}) {
return overrides as never;
}
// ---------------------------------------------------------------------------
// buildSkillStatPatch
// ---------------------------------------------------------------------------
describe("buildSkillStatPatch", () => {
it("scenario 1: top-level fields present and already in sync with nested → returns null", () => {
const skill = makeSkill({
statsDownloads: 10,
statsStars: 5,
statsInstallsCurrent: 3,
statsInstallsAllTime: 20,
stats: { downloads: 10, stars: 5, installsCurrent: 3, installsAllTime: 20, comments: 1 },
});
expect(buildSkillStatPatch(skill)).toBeNull();
});
it("scenario 2: top-level fields present but nested fields are stale → patches nested to match top-level", () => {
const skill = makeSkill({
statsDownloads: 10,
statsStars: 5,
statsInstallsCurrent: 3,
statsInstallsAllTime: 20,
stats: { downloads: 1, stars: 1, installsCurrent: 0, installsAllTime: 0, comments: 0 },
});
const patch = buildSkillStatPatch(skill);
expect(patch).not.toBeNull();
// Top-level fields must be written with the canonical (top-level) values.
expect(patch!.statsDownloads).toBe(10);
expect(patch!.statsStars).toBe(5);
expect(patch!.statsInstallsCurrent).toBe(3);
expect(patch!.statsInstallsAllTime).toBe(20);
// Nested fields must be brought in sync with the top-level values.
expect(patch!.stats.downloads).toBe(10);
expect(patch!.stats.stars).toBe(5);
expect(patch!.stats.installsCurrent).toBe(3);
expect(patch!.stats.installsAllTime).toBe(20);
});
it("scenario 3: top-level fields absent (pre-migration doc) → reads from nested, writes both sets", () => {
const skill = makeSkill({
// No statsDownloads / statsStars / etc. — pre-migration document.
stats: { downloads: 7, stars: 3, installsCurrent: 2, installsAllTime: 15, comments: 4 },
});
const patch = buildSkillStatPatch(skill);
expect(patch).not.toBeNull();
// Top-level fields must be populated from the nested values.
expect(patch!.statsDownloads).toBe(7);
expect(patch!.statsStars).toBe(3);
expect(patch!.statsInstallsCurrent).toBe(2);
expect(patch!.statsInstallsAllTime).toBe(15);
// Nested fields must remain consistent.
expect(patch!.stats.downloads).toBe(7);
expect(patch!.stats.stars).toBe(3);
expect(patch!.stats.installsCurrent).toBe(2);
expect(patch!.stats.installsAllTime).toBe(15);
});
it("scenario 4: top-level fields present but nested is out of sync → patches nested to match top-level (not the other way around)", () => {
// This is the exact bug that was previously shipped: the old code wrote
// nested → top-level instead of top-level → nested.
const skill = makeSkill({
statsDownloads: 100,
statsStars: 50,
statsInstallsCurrent: 30,
statsInstallsAllTime: 200,
stats: { downloads: 1, stars: 1, installsCurrent: 1, installsAllTime: 1, comments: 0 },
});
const patch = buildSkillStatPatch(skill);
expect(patch).not.toBeNull();
// The canonical top-level values must win.
expect(patch!.statsDownloads).toBe(100);
expect(patch!.statsStars).toBe(50);
expect(patch!.statsInstallsCurrent).toBe(30);
expect(patch!.statsInstallsAllTime).toBe(200);
// The stale nested values must be overwritten by the top-level values.
expect(patch!.stats.downloads).toBe(100);
expect(patch!.stats.stars).toBe(50);
expect(patch!.stats.installsCurrent).toBe(30);
expect(patch!.stats.installsAllTime).toBe(200);
});
it("preserves unrelated nested fields (e.g. comments) when patching stat fields", () => {
const skill = makeSkill({
statsDownloads: 5,
statsStars: 2,
statsInstallsCurrent: 1,
statsInstallsAllTime: 10,
stats: { downloads: 0, stars: 0, installsCurrent: 0, installsAllTime: 0, comments: 99 },
});
const patch = buildSkillStatPatch(skill);
expect(patch).not.toBeNull();
// comments is not a stat field managed by buildSkillStatPatch — it must be
// carried over unchanged from the original nested object.
expect(patch!.stats.comments).toBe(99);
});
});
// ---------------------------------------------------------------------------
// reconcileSkillStarCountsHandler
// ---------------------------------------------------------------------------
describe("reconcileSkillStarCounts", () => {
/**
* Build a minimal db mock that returns a single-page result for skills and
* configurable star / comment record counts.
*/
function makeCtx(options: {
skill: {
_id: string;
statsStars?: number;
stats: { stars: number; comments: number };
softDeletedAt?: number;
};
actualStarCount: number;
actualCommentCount: number;
}) {
const { skill, actualStarCount, actualCommentCount } = options;
const starRecords = Array.from({ length: actualStarCount }, (_, i) => ({
_id: `stars:${i}`,
skillId: skill._id,
}));
const commentRecords = Array.from({ length: actualCommentCount }, (_, i) => ({
_id: `comments:${i}`,
skillId: skill._id,
softDeletedAt: undefined,
}));
const paginate = vi.fn().mockResolvedValue({
page: [skill],
continueCursor: null,
isDone: true,
});
const collect = vi
.fn()
.mockResolvedValueOnce(starRecords)
.mockResolvedValueOnce(commentRecords);
const withIndex = vi.fn().mockReturnValue({ collect });
const patch = vi.fn().mockResolvedValue(undefined);
const ctx = {
db: {
query: vi.fn().mockReturnValue({
order: vi.fn().mockReturnValue({ paginate }),
withIndex,
}),
patch,
},
} as never;
return { ctx, patch };
}
it("reads from top-level statsStars (canonical path) when deciding whether to patch", async () => {
// statsStars is correct (matches actual count), but stats.stars is stale.
// The reconcile job uses the canonical read path (top-level preferred), so
// it should NOT trigger a patch based on the star count alone.
const skill = {
_id: "skills:1",
statsStars: 5, // canonical value — correct
stats: { stars: 99, comments: 0 }, // legacy value — stale, but not reconcile's concern
};
const { ctx, patch } = makeCtx({ skill, actualStarCount: 5, actualCommentCount: 0 });
const result = await reconcileSkillStarCountsHandler(ctx, {});
expect(result.scanned).toBe(1);
expect(result.patched).toBe(0);
expect(patch).not.toHaveBeenCalled();
});
it("falls back to stats.stars when statsStars is absent (pre-migration doc)", async () => {
// Pre-migration doc: no top-level statsStars. The canonical read path
// falls back to stats.stars. If that also matches actual count, no patch.
const skill = {
_id: "skills:1",
// statsStars intentionally absent
stats: { stars: 3, comments: 0 },
};
const { ctx, patch } = makeCtx({ skill, actualStarCount: 3, actualCommentCount: 0 });
const result = await reconcileSkillStarCountsHandler(ctx, {});
expect(result.scanned).toBe(1);
expect(result.patched).toBe(0);
expect(patch).not.toHaveBeenCalled();
});
it("patches both statsStars and stats.stars when canonical value drifts from actual count", async () => {
const skill = {
_id: "skills:1",
statsStars: 10, // canonical value — out of sync with actual
stats: { stars: 10, comments: 0 },
};
const { ctx, patch } = makeCtx({ skill, actualStarCount: 7, actualCommentCount: 0 });
const result = await reconcileSkillStarCountsHandler(ctx, {});
expect(result.scanned).toBe(1);
expect(result.patched).toBe(1);
expect(patch).toHaveBeenCalledWith("skills:1", expect.objectContaining({
statsStars: 7,
stats: expect.objectContaining({ stars: 7 }),
}));
});
it("patches when comment count drifts even if star count is correct", async () => {
const skill = {
_id: "skills:1",
statsStars: 5,
stats: { stars: 5, comments: 10 }, // comments out of sync
};
const { ctx, patch } = makeCtx({ skill, actualStarCount: 5, actualCommentCount: 3 });
const result = await reconcileSkillStarCountsHandler(ctx, {});
expect(result.scanned).toBe(1);
expect(result.patched).toBe(1);
expect(patch).toHaveBeenCalledWith("skills:1", expect.objectContaining({
stats: expect.objectContaining({ comments: 3 }),
}));
});
it("skips soft-deleted skills", async () => {
const skill = {
_id: "skills:1",
softDeletedAt: 12345,
statsStars: 0,
stats: { stars: 0, comments: 0 },
};
const { ctx, patch } = makeCtx({ skill, actualStarCount: 5, actualCommentCount: 0 });
const result = await reconcileSkillStarCountsHandler(ctx, {});
// Soft-deleted skills are excluded from scanned count and never patched.
expect(result.scanned).toBe(0);
expect(result.patched).toBe(0);
expect(patch).not.toHaveBeenCalled();
});
});
+58 -107
View File
@@ -183,53 +183,25 @@ export const runSkillStatBackfillInternal: ReturnType<typeof internalAction> = i
function buildSkillStatPatch(skill: Doc<"skills">) {
const stats = skill.stats;
const nextDownloads = stats.downloads;
const nextStars = stats.stars;
const nextInstallsCurrent = stats.installsCurrent ?? 0;
const nextInstallsAllTime = stats.installsAllTime ?? 0;
// Prefer the top-level stat fields when they exist (they are kept up-to-date
// by applySkillStatDeltas on every event flush). Fall back to the legacy
// nested `stats` object only for documents that pre-date the migration.
const nextDownloads =
typeof skill.statsDownloads === "number" ? skill.statsDownloads : stats.downloads;
const nextStars =
typeof skill.statsStars === "number" ? skill.statsStars : stats.stars;
const nextInstallsCurrent =
typeof skill.statsInstallsCurrent === "number"
? skill.statsInstallsCurrent
: (stats.installsCurrent ?? 0);
const nextInstallsAllTime =
typeof skill.statsInstallsAllTime === "number"
? skill.statsInstallsAllTime
: (stats.installsAllTime ?? 0);
// Check whether both sets of fields are already in sync.
const topLevelInSync =
if (
skill.statsDownloads === nextDownloads &&
skill.statsStars === nextStars &&
skill.statsInstallsCurrent === nextInstallsCurrent &&
skill.statsInstallsAllTime === nextInstallsAllTime;
const nestedInSync =
stats.downloads === nextDownloads &&
stats.stars === nextStars &&
(stats.installsCurrent ?? 0) === nextInstallsCurrent &&
(stats.installsAllTime ?? 0) === nextInstallsAllTime;
if (topLevelInSync && nestedInSync) {
skill.statsInstallsAllTime === nextInstallsAllTime
) {
return null;
}
// Write both sets of fields so they stay in sync.
return {
statsDownloads: nextDownloads,
statsStars: nextStars,
statsInstallsCurrent: nextInstallsCurrent,
statsInstallsAllTime: nextInstallsAllTime,
stats: {
...stats,
downloads: nextDownloads,
stars: nextStars,
installsCurrent: nextInstallsCurrent,
installsAllTime: nextInstallsAllTime,
},
};
}
@@ -243,79 +215,63 @@ function buildSkillStatPatch(skill: Doc<"skills">) {
*
* Downloads and installs are event-sourced only (no separate table to count from),
* so they cannot be reconciled this way.
*
* Exported as a standalone function so it can be unit-tested directly without
* going through the Convex internalMutation wrapper.
*/
export async function reconcileSkillStarCountsHandler(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ctx: { db: { query: any; patch: any } },
args: { cursor?: string; batchSize?: number },
) {
const batchSize = clampInt(args.batchSize ?? 50, 1, 200);
const now = Date.now();
const { page, isDone, continueCursor } = await ctx.db
.query("skills")
.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")
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.withIndex("by_skill_user", (q: any) => q.eq("skillId", skill._id))
.collect();
const actualStars = starRecords.length;
// Count actual comment records for this skill
const commentRecords = await ctx.db
.query("comments")
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.withIndex("by_skill", (q: any) => q.eq("skillId", skill._id))
.collect();
const actualComments = commentRecords.filter((c: { softDeletedAt?: unknown }) => !c.softDeletedAt).length;
// Check if stats are out of sync (compare against the canonical value
// used by toPublicSkill: prefer top-level field, fall back to nested).
const currentStars =
typeof skill.statsStars === "number" ? skill.statsStars : skill.stats.stars;
if (currentStars !== actualStars || skill.stats.comments !== actualComments) {
const updatedStats = {
...skill.stats,
stars: actualStars,
comments: actualComments,
};
// Keep both the top-level index field and the legacy nested field in sync.
await ctx.db.patch(skill._id, {
statsStars: actualStars,
stats: updatedStats,
updatedAt: now,
});
patched += 1;
}
}
return {
scanned,
patched,
cursor: isDone ? null : continueCursor,
isDone,
};
}
export const reconcileSkillStarCounts = internalMutation({
args: {
cursor: v.optional(v.string()),
batchSize: v.optional(v.number()),
},
handler: reconcileSkillStarCountsHandler,
handler: async (ctx, args) => {
const batchSize = clampInt(args.batchSize ?? 50, 1, 200);
const now = Date.now();
const { page, isDone, continueCursor } = await ctx.db
.query("skills")
.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")
.withIndex("by_skill_user", (q) => q.eq("skillId", skill._id))
.collect();
const actualStars = starRecords.length;
// Count actual comment records for this skill
const commentRecords = await ctx.db
.query("comments")
.withIndex("by_skill", (q) => q.eq("skillId", skill._id))
.collect();
const actualComments = commentRecords.filter((c) => !c.softDeletedAt).length;
// Check if stats are out of sync
if (skill.stats.stars !== actualStars || skill.stats.comments !== actualComments) {
const updatedStats = {
...skill.stats,
stars: actualStars,
comments: actualComments,
};
await ctx.db.patch(skill._id, {
statsStars: actualStars,
stats: updatedStats,
updatedAt: now,
});
patched += 1;
}
}
return {
scanned,
patched,
cursor: isDone ? null : continueCursor,
isDone,
};
},
});
export const runReconcileSkillStarCountsInternal = internalAction({
@@ -352,11 +308,6 @@ function clampInt(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
// Exported for unit testing only — not part of the public API.
export const __test = {
buildSkillStatPatch,
};
/**
* Count a page of skillSearchDigest docs and return the partial public count.
* Each query runs in its own transaction (~1000 docs, ~900 KB), well under limits.
-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",
+20 -58
View File
@@ -1,17 +1,22 @@
import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
import type { ActionCtx, MutationCtx } from "./_generated/server";
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
import { assertAdmin, assertModerator, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
import {
assertAdmin,
assertModerator,
getOptionalActiveAuthUserId,
requireUser,
} from "./lib/access";
import { syncGitHubProfile } from "./lib/githubAccount";
import { toPublicUser } from "./lib/public";
import {
ensurePersonalPublisherForUser,
getActiveUserByHandleOrPersonalPublisher,
getPublisherByHandle,
getUserByHandleOrPersonalPublisher,
} from "./lib/publishers";
import { toPublicUser } from "./lib/public";
import {
getLatestActiveReservedHandle,
isHandleReservedForAnotherUser,
@@ -296,7 +301,9 @@ export async function ensureHandler(ctx: MutationCtx) {
updates.updatedAt = Date.now();
await ctx.db.patch(userId, updates);
}
const ensuredUser = hasUpdates ? ({ ...user, ...updates } as Doc<"users">) : ((await ctx.db.get(userId)) ?? user);
const ensuredUser = hasUpdates
? ({ ...user, ...updates } as Doc<"users">)
: ((await ctx.db.get(userId)) ?? user);
await ensurePersonalPublisherForUser(ctx, ensuredUser);
return await ctx.db.get(userId);
}
@@ -386,23 +393,6 @@ export const list = query({
},
});
export const listPublic = query({
args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
handler: async (ctx, args) => {
const limit = clampInt(args.limit ?? 40, 1, 100);
const result = await queryUsersForPublicList(ctx, {
limit,
search: args.search,
});
return {
items: result.items
.map((user) => toPublicUser(user))
.filter((user): user is NonNullable<ReturnType<typeof toPublicUser>> => Boolean(user)),
total: result.total,
};
},
});
function normalizeSearchQuery(search?: string) {
const trimmed = search?.trim().toLowerCase();
return trimmed ? trimmed : undefined;
@@ -413,7 +403,13 @@ function computeUserSearchScanLimit(limit: number) {
}
async function queryUsersForAdminList(
ctx: Pick<QueryCtx, "db">,
ctx: {
db: {
query: (table: "users") => {
order: (order: "desc") => { take: (n: number) => Promise<Doc<"users">[]> };
};
};
},
args: { limit: number; search?: string; exactUserId?: Id<"users"> },
) {
const normalizedSearch = normalizeSearchQuery(args.search);
@@ -435,27 +431,6 @@ async function queryUsersForAdminList(
};
}
async function queryUsersForPublicList(
ctx: Pick<QueryCtx, "db">,
args: { limit: number; search?: string },
) {
const normalizedSearch = normalizeSearchQuery(args.search);
const scanLimit = normalizedSearch
? computeUserSearchScanLimit(args.limit)
: clampInt(args.limit * 6, args.limit, MAX_USER_SEARCH_SCAN);
const scannedUsers = await ctx.db
.query("users")
.withIndex("by_active_handle", (q) => q.eq("deletedAt", undefined).eq("deactivatedAt", undefined))
.order("desc")
.take(scanLimit);
const activeUsers = scannedUsers.filter((user) => Boolean(user.handle));
const result = buildUserSearchResults(activeUsers, normalizedSearch);
return {
items: result.items.slice(0, args.limit),
total: result.total,
};
}
function clampInt(value: number, min: number, max: number) {
return Math.min(Math.max(Math.trunc(value), min), max);
}
@@ -467,20 +442,6 @@ export const getByHandle = query({
},
});
/** Lightweight stats for user hover tooltips. Uses the skills by_owner index. */
export const getHoverStats = query({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
return {
publishedSkills: user?.publishedSkills ?? 0,
totalStars: user?.totalStars ?? 0,
totalDownloads: user?.totalDownloads ?? 0,
};
},
});
export const getReservedHandleInternal = internalQuery({
args: { handle: v.string() },
handler: async (ctx, args) => {
@@ -899,7 +860,8 @@ async function ensurePublisherHandleWithActor(
if (existing) {
const nextDisplayName =
args.displayName?.trim() && (!existing.displayName || existing.displayName === existing.handle)
args.displayName?.trim() &&
(!existing.displayName || existing.displayName === existing.handle)
? displayName
: existing.displayName;
await ctx.db.patch(existing._id, {
+5 -6
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
@@ -180,13 +177,15 @@ Stores your API token + cached registry URL.
### `transfer`
- Ownership transfer workflow.
- Skill ownership transfer workflow.
- Supports user-to-user, user-to-org, org-to-user, and org-to-org transfers.
- Subcommands:
- `transfer request <slug> <handle> [--message "..."] [--yes]`
- `transfer request <slug> <handle> [--message "..."] [--publisher @org] [--yes]`
- `transfer list [--outgoing]`
- `transfer accept <slug> [--yes]`
- `transfer accept <slug> [--publisher @org] [--yes]`
- `transfer reject <slug> [--yes]`
- `transfer cancel <slug> [--yes]`
- `--publisher @org`: on request, targets the transfer to an org (recipient must be org admin to accept). On accept, assigns ownership to the org instead of the accepting user's personal publisher.
- Endpoints:
- `POST /api/v1/skills/{slug}/transfer`
- `POST /api/v1/skills/{slug}/transfer/accept`
+19 -2
View File
@@ -478,16 +478,33 @@ Notes:
### Transfer ownership endpoints
Transfers support user-to-user, user-to-org, org-to-user, and org-to-org flows for skills. Org-targeted transfers require the actor to hold `admin` or `owner` role on the relevant publisher.
#### Skill transfers
- `POST /api/v1/skills/{slug}/transfer`
- Body: `{ "toUserHandle": "target_handle", "message": "optional" }`
- Body: `{ "toUserHandle": "target_handle", "message": "optional", "toPublisherHandle": "optional_org_handle" }`
- When `toPublisherHandle` is provided, the transfer targets the org. The recipient (or any org admin) accepts on behalf of the org.
- Response: `{ "ok": true, "transferId": "skillOwnershipTransfers:...", "toUserHandle": "target_handle", "expiresAt": 1730000000000 }`
- `POST /api/v1/skills/{slug}/transfer/accept`
- Optional body: `{ "publisherHandle": "org_handle" }` — assign the skill to an org instead of the accepting user's personal publisher.
- `POST /api/v1/skills/{slug}/transfer/reject`
- `POST /api/v1/skills/{slug}/transfer/cancel`
- Response (accept/reject/cancel): `{ "ok": true, "skillSlug": "demo-skill?" }`
#### Transfer listing
- `GET /api/v1/transfers/incoming`
- `GET /api/v1/transfers/outgoing`
- Response shape: `{ "transfers": [{ "_id": "...", "skill": { "slug": "demo", "displayName": "Demo" }, "fromUser"|"toUser": { "handle": "..." }, "message": "...", "requestedAt": 0, "expiresAt": 0 }] }`
- Returns skill transfers, sorted by `requestedAt` descending.
- Response shape: `{ "transfers": [{ "_id": "...", "type": "skill", "skill": { "slug": "demo", "displayName": "Demo" }, "fromUser"|"toUser": { "handle": "..." }, "message": "...", "requestedAt": 0, "expiresAt": 0 }] }`
#### Transfer rules
- Pending transfers expire after 7 days.
- Only one pending transfer per skill at a time.
- Ownership is re-validated at accept time; if ownership changed since the request, the transfer is auto-cancelled.
- Org transfers require `admin` or `owner` role on the source/target publisher.
### `POST /api/v1/users/ban`
+6 -9
View File
@@ -325,12 +325,9 @@ Semantics:
## Transfer Model
Current transfers are user-to-user only. That is too narrow.
New transfer target should be a publisher.
Support:
Transfers support publisher-based flows for both skills and packages:
- user publisher -> user publisher
- user publisher -> org publisher
- org publisher -> user publisher
- org publisher -> org publisher
@@ -339,12 +336,12 @@ Transfer acceptance rule:
- actor must have `owner` or `admin` on target publisher
Audit should record:
Audit records:
- actor user id
- source publisher id
- target publisher id
- resource id
- source publisher id (`fromPublisherId`)
- target publisher id (`toPublisherId`)
- resource id (skill or package)
## Search Digest Changes
-6
View File
@@ -46,8 +46,6 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -60,7 +58,6 @@
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "1.167.2",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clawhub-schema": "workspace:*",
"clsx": "^2.1.1",
"convex": "^1.34.1",
@@ -70,8 +67,6 @@
"ignore": "^7.0.5",
"lucide-react": "^0.577.0",
"monaco-editor": "^0.55.1",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"nitro": "3.0.260311-beta",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -82,7 +77,6 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"vite-tsconfig-paths": "^6.1.1",
"yaml": "^2.8.3",
"zod": "^4.3.6"
+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
+6 -4
View File
@@ -503,10 +503,11 @@ const transfer = program.command("transfer").description("Transfer skill ownersh
transfer
.command("request")
.description("Request skill transfer to another user")
.description("Request skill transfer to another user or organization")
.argument("<slug>", "Skill slug")
.argument("<handle>", "Recipient handle (e.g., @username)")
.option("--message <text>", "Optional message for recipient")
.option("--publisher <handle>", "Target org publisher handle")
.option("--yes", "Skip confirmation")
.action(async (slug, handle, options) => {
const opts = await resolveGlobalOpts();
@@ -524,8 +525,9 @@ transfer
transfer
.command("accept")
.description("Accept incoming transfer for a skill")
.description("Accept incoming skill transfer")
.argument("<slug>", "Skill slug")
.option("--publisher <handle>", "Accept to org publisher instead of personal")
.option("--yes", "Skip confirmation")
.action(async (slug, options) => {
const opts = await resolveGlobalOpts();
@@ -534,7 +536,7 @@ transfer
transfer
.command("reject")
.description("Reject incoming transfer for a skill")
.description("Reject incoming skill transfer")
.argument("<slug>", "Skill slug")
.option("--yes", "Skip confirmation")
.action(async (slug, options) => {
@@ -544,7 +546,7 @@ transfer
transfer
.command("cancel")
.description("Cancel outgoing transfer for a skill")
.description("Cancel outgoing skill transfer")
.argument("<slug>", "Skill slug")
.option("--yes", "Skip confirmation")
.action(async (slug, options) => {
@@ -64,8 +64,10 @@ describe("transfer commands", () => {
}),
expect.anything(),
);
const requestArgs = httpMocks.apiRequest.mock.calls[0]?.[1] as { body?: string };
expect(requestArgs.body).toContain('"toUserHandle":"alice"');
const requestArgs = httpMocks.apiRequest.mock.calls[0]?.[1] as {
body?: Record<string, string>;
};
expect(requestArgs.body?.toUserHandle).toBe("alice");
});
it("list calls incoming transfers endpoint", async () => {
+20 -8
View File
@@ -63,7 +63,7 @@ export async function cmdTransferRequest(
opts: GlobalOpts,
slugArg: string,
toHandleArg: string,
options: ConfirmOptions & { message?: string },
options: ConfirmOptions & { message?: string; publisher?: string },
inputAllowed: boolean,
) {
const slug = normalizeSlug(slugArg);
@@ -73,7 +73,7 @@ export async function cmdTransferRequest(
const confirmed = await requireYesOrConfirm(
options,
inputAllowed,
`Transfer ${slug} to @${toHandle}? Recipient must accept.`,
`Transfer skill "${slug}" to @${toHandle}? Recipient must accept.`,
);
if (!confirmed) return;
@@ -82,16 +82,21 @@ export async function cmdTransferRequest(
const spinner = createSpinner(`Requesting transfer of ${slug} to @${toHandle}`);
try {
const body: Record<string, string | undefined> = {
toUserHandle: toHandle,
message: options.message,
};
if (options.publisher) {
body.toPublisherHandle = options.publisher.replace(/^@+/, "");
}
const result = await apiRequest(
registry,
{
method: "POST",
path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}/transfer`,
token,
body: JSON.stringify({
toUserHandle: toHandle,
message: options.message,
}),
body,
},
ApiV1TransferRequestResponseSchema,
);
@@ -150,11 +155,12 @@ export async function cmdTransferList(opts: GlobalOpts, options: { outgoing?: bo
async function runTransferDecision(
opts: GlobalOpts,
slugArg: string,
options: ConfirmOptions,
options: ConfirmOptions & { publisher?: string },
inputAllowed: boolean,
spec: DecisionSpec,
) {
const slug = normalizeSlug(slugArg);
const confirmed = await requireYesOrConfirm(
options,
inputAllowed,
@@ -167,12 +173,18 @@ async function runTransferDecision(
const spinner = createSpinner(`${spec.progress} transfer of ${slug}`);
try {
const body: Record<string, string> | undefined =
spec.action === "accept" && options.publisher
? { publisherHandle: options.publisher.replace(/^@+/, "") }
: undefined;
const result = await apiRequest(
registry,
{
method: "POST",
path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}/transfer/${spec.action}`,
token,
...(body ? { body } : {}),
},
ApiV1TransferDecisionResponseSchema,
);
@@ -188,7 +200,7 @@ async function runTransferDecision(
export function cmdTransferAccept(
opts: GlobalOpts,
slugArg: string,
options: ConfirmOptions,
options: ConfirmOptions & { publisher?: string },
inputAllowed: boolean,
) {
return runTransferDecision(opts, slugArg, options, inputAllowed, DECISION_SPECS.accept);
-21
View File
@@ -3,7 +3,6 @@
import { describe, expect, it, vi } from "vitest";
const mockSpawn = vi.fn();
const originalPlatform = process.platform;
vi.mock("node:child_process", () => ({
spawn: (...args: unknown[]) => mockSpawn(...args),
@@ -27,26 +26,6 @@ function createMockChild() {
}
describe("openInBrowser", () => {
it("uses explorer on Windows and preserves query params in the URL argument", () => {
const child = createMockChild();
mockSpawn.mockReturnValueOnce(child);
const url =
"https://clawhub.ai/auth?redirect_uri=http%3A%2F%2F127.0.0.1%3A43123%2Fcallback&state=abc123";
try {
Object.defineProperty(process, "platform", { value: "win32" });
openInBrowser(url);
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform });
}
expect(mockSpawn).toHaveBeenCalledWith("explorer", [url], {
stdio: "ignore",
detached: true,
});
expect(child.unref).toHaveBeenCalledOnce();
});
it("prints manual URL instructions when browser opener is missing", () => {
const child = createMockChild();
mockSpawn.mockReturnValueOnce(child);
+1 -1
View File
@@ -48,7 +48,7 @@ export function openInBrowser(url: string) {
process.platform === "darwin"
? ["open", url]
: process.platform === "win32"
? ["explorer", url]
? ["cmd", "/c", "start", "", url]
: ["xdg-open", url];
const [command, ...commandArgs] = args;
if (!command) return;
-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;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 792 B

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

+1 -1
View File
@@ -21,5 +21,5 @@
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#0a0a0a"
"background_color": "#ffffff"
}
+98
View File
@@ -0,0 +1,98 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1200" y2="630" gradientUnits="userSpaceOnUse">
<stop stop-color="#14110F"/>
<stop offset="0.55" stop-color="#1A1512"/>
<stop offset="1" stop-color="#14110F"/>
</linearGradient>
<radialGradient id="glowOrange" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(260 60) rotate(120) scale(520 420)">
<stop stop-color="#E86A47" stop-opacity="0.55"/>
<stop offset="1" stop-color="#E86A47" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glowSea" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(1050 120) rotate(140) scale(520 420)">
<stop stop-color="#4AD8B7" stop-opacity="0.35"/>
<stop offset="1" stop-color="#4AD8B7" stop-opacity="0"/>
</radialGradient>
<filter id="softBlur" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="24"/>
</filter>
<filter id="cardShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="18" stdDeviation="26" flood-color="#000000" flood-opacity="0.6"/>
</filter>
<linearGradient id="pill" x1="0" y1="0" x2="360" y2="0" gradientUnits="userSpaceOnUse">
<stop stop-color="#E86A47" stop-opacity="0.22"/>
<stop offset="1" stop-color="#E86A47" stop-opacity="0.08"/>
</linearGradient>
<linearGradient id="stroke" x1="0" y1="0" x2="0" y2="1">
<stop stop-color="#FFFFFF" stop-opacity="0.16"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.06"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="1200" height="630" fill="url(#bg)"/>
<circle cx="260" cy="60" r="520" fill="url(#glowOrange)" filter="url(#softBlur)"/>
<circle cx="1050" cy="120" r="520" fill="url(#glowSea)" filter="url(#softBlur)"/>
<!-- Subtle grain (very light) -->
<g opacity="0.08">
<path d="M0 84 C160 120 340 40 520 86 C700 132 820 210 1200 160" stroke="#FFFFFF" stroke-opacity="0.10" stroke-width="2"/>
<path d="M0 188 C220 240 360 160 560 204 C760 248 900 330 1200 300" stroke="#FFFFFF" stroke-opacity="0.08" stroke-width="2"/>
<path d="M0 440 C240 380 420 520 620 470 C820 420 960 500 1200 460" stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="2"/>
</g>
<!-- Right mark -->
<g opacity="0.22" filter="url(#softBlur)">
<image href="clawd-mark.png" x="740" y="70" width="560" height="560" preserveAspectRatio="xMidYMid meet"/>
</g>
<!-- Content card -->
<g filter="url(#cardShadow)">
<rect x="72" y="96" width="640" height="438" rx="34" fill="#201B18" fill-opacity="0.92" stroke="url(#stroke)"/>
</g>
<!-- Tiny mark -->
<image href="clawd-mark.png" x="108" y="134" width="46" height="46" preserveAspectRatio="xMidYMid meet"/>
<!-- Pill -->
<g>
<rect x="166" y="136" width="304" height="42" rx="21" fill="url(#pill)" stroke="#E86A47" stroke-opacity="0.28"/>
<text x="186" y="163"
fill="#F6EFE4"
font-size="18"
font-weight="600"
font-family="Bricolage Grotesque, Manrope, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif"
opacity="0.92">lobster-light. agent-right.</text>
</g>
<!-- Title -->
<text x="112" y="265"
fill="#F6EFE4"
font-size="92"
font-weight="700"
font-family="Bricolage Grotesque, Manrope, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif">ClawHub</text>
<!-- Subtitle -->
<text x="114" y="332"
fill="#C6B8A8"
font-size="23"
font-weight="500"
font-family="Manrope, Bricolage Grotesque, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif">
a fast skill registry for agents, with vector search.
</text>
<!-- Accent line + hint -->
<rect x="114" y="372" width="110" height="6" rx="3" fill="#E86A47"/>
<text x="114" y="430"
fill="#F6EFE4"
font-size="20"
font-weight="600"
opacity="0.90"
font-family="IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace">clawhub.ai</text>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

-51
View File
@@ -1,51 +0,0 @@
/* @vitest-environment jsdom */
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { renderWithInlineCode } from "../routes/about";
function renderToContainer(text: string) {
const { container } = render(<p>{renderWithInlineCode(text)}</p>);
return container.querySelector("p")!;
}
describe("renderWithInlineCode", () => {
it("returns plain text unchanged when no backticks present", () => {
const el = renderToContainer("No code here.");
expect(el.textContent).toBe("No code here.");
expect(el.querySelectorAll("code")).toHaveLength(0);
});
it("wraps backtick-delimited text in <code> elements", () => {
const el = renderToContainer("Run `curl | sh` to install.");
const codes = el.querySelectorAll("code");
expect(codes).toHaveLength(1);
expect(codes[0].textContent).toBe("curl | sh");
expect(codes[0].className).toBe("about-inline-code");
expect(el.textContent).toBe("Run curl | sh to install.");
});
it("handles multiple code spans in a single string", () => {
const el = renderToContainer(
"Use `curl | sh` or `npx @latest` for setup."
);
const codes = el.querySelectorAll("code");
expect(codes).toHaveLength(2);
expect(codes[0].textContent).toBe("curl | sh");
expect(codes[1].textContent).toBe("npx @latest");
});
it("handles empty input string", () => {
const el = renderToContainer("");
expect(el.textContent).toBe("");
expect(el.querySelectorAll("code")).toHaveLength(0);
});
it("handles string that is only a code span", () => {
const el = renderToContainer("`only-code`");
const codes = el.querySelectorAll("code");
expect(codes).toHaveLength(1);
expect(codes[0].textContent).toBe("only-code");
expect(el.textContent).toBe("only-code");
});
});
+16 -81
View File
@@ -1,26 +1,12 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import Header from "../components/Header";
const siteModeMock = vi.fn(() => "souls");
const navigateMock = vi.fn();
vi.mock("@tanstack/react-router", () => ({
Link: (props: {
children: ReactNode;
className?: string;
hash?: string;
to?: string;
}) => (
<a href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`} className={props.className}>
{props.children}
</a>
),
useLocation: () => ({ pathname: "/" }),
useNavigate: () => navigateMock,
Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
}));
vi.mock("@convex-dev/auth/react", () => ({
@@ -30,30 +16,19 @@ vi.mock("@convex-dev/auth/react", () => ({
}),
}));
const authStatusMock = vi.fn(() => ({
isAuthenticated: false,
isLoading: false,
me: null,
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => authStatusMock(),
useAuthStatus: () => ({
isAuthenticated: false,
isLoading: false,
me: null,
}),
}));
const setThemeMock = vi.fn();
const setModeMock = vi.fn();
vi.mock("../lib/theme", () => ({
applyTheme: vi.fn(),
THEME_OPTIONS: [
{ value: "claw", label: "Claw", description: "" },
{ value: "hub", label: "Hub", description: "" },
],
useThemeMode: () => ({
theme: "hub",
mode: "system",
setTheme: setThemeMock,
setMode: setModeMock,
setMode: vi.fn(),
}),
}));
@@ -81,10 +56,14 @@ vi.mock("../lib/roles", () => ({
vi.mock("../lib/site", () => ({
getClawHubSiteUrl: () => "https://clawhub.ai",
getSiteMode: () => siteModeMock(),
getSiteMode: () => "souls",
getSiteName: () => "OnlyCrabs",
}));
vi.mock("../lib/convexError", () => ({
getUserFacingConvexError: vi.fn(),
}));
vi.mock("../lib/gravatar", () => ({
gravatarUrl: vi.fn(),
}));
@@ -99,59 +78,15 @@ vi.mock("../components/ui/dropdown-menu", () => ({
vi.mock("../components/ui/toggle-group", () => ({
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ToggleGroupItem: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
<button type="button">{children}</button>
),
}));
describe("Header", () => {
it("hides Packages navigation in soul mode on mobile and desktop", () => {
siteModeMock.mockReturnValue("souls");
render(<Header />);
expect(screen.queryByText("Packages")).toBeNull();
});
it("renders direct desktop theme family controls and plain Skills tab", () => {
siteModeMock.mockReturnValue("skills");
setThemeMock.mockClear();
setModeMock.mockClear();
render(<Header />);
expect(screen.getByRole("button", { name: /Cycle theme mode/i })).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);
expect(screen.getAllByText("Users")).toHaveLength(1);
expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy();
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("Users")).toHaveLength(2);
});
it("routes soul-mode header searches to the souls browse page", () => {
siteModeMock.mockReturnValue("souls");
navigateMock.mockReset();
render(<Header />);
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
target: { value: "angler" },
});
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
expect(navigateMock).toHaveBeenCalledWith({
to: "/souls",
search: {
q: "angler",
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
},
});
});
});
-1
View File
@@ -17,7 +17,6 @@ const useAuthStatusMock = vi.fn();
let useActionCallCount = 0;
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useAction: () => {
const action = [previewImport, previewCandidate, importSkill][useActionCallCount % 3];
@@ -26,7 +26,6 @@ const useAuthStatusMock = vi.fn();
const originalFetch = globalThis.fetch;
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useMutation: () => generateUploadUrl,
useAction: () => publishRelease,
useQuery: () => undefined,
@@ -277,12 +276,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();
});
+1 -38
View File
@@ -28,7 +28,6 @@ let loaderDataMock: {
nextCursor: string | null;
rateLimited: boolean;
retryAfterSeconds: number | null;
apiError?: boolean;
} = {
items: [],
nextCursor: null,
@@ -73,13 +72,7 @@ describe("plugins route", () => {
isRateLimitedPackageApiErrorMock.mockClear();
navigateMock.mockReset();
searchMock = {};
loaderDataMock = {
items: [],
nextCursor: null,
rateLimited: false,
retryAfterSeconds: null,
apiError: false,
};
loaderDataMock = { items: [], nextCursor: null, rateLimited: false, retryAfterSeconds: null };
});
it("rejects skill family filter in search state", async () => {
@@ -230,36 +223,6 @@ describe("plugins route", () => {
nextCursor: null,
rateLimited: true,
retryAfterSeconds: 22,
apiError: false,
});
});
it("flags API errors for filtered catalog requests", async () => {
fetchPluginCatalogMock.mockRejectedValue(new Error("boom"));
const route = await loadRoute();
const loader = route.__config.loader as (args: {
deps: Record<string, unknown>;
}) => Promise<{
items: Array<{ name: string }>;
nextCursor: string | null;
rateLimited: boolean;
retryAfterSeconds: number | null;
apiError?: boolean;
}>;
const result = await loader({
deps: {
q: "demo",
executesCode: true,
},
});
expect(result).toEqual({
items: [],
nextCursor: null,
rateLimited: false,
retryAfterSeconds: null,
apiError: true,
});
});
+72 -47
View File
@@ -1,74 +1,99 @@
import { describe, expect, it, vi } from "vitest";
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
vi.mock("../convex/client", () => ({
convex: {},
convexHttp: { query: vi.fn() },
}));
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: { validateSearch?: unknown; component?: unknown }) => ({
__config: config,
}),
createFileRoute: () => (config: { beforeLoad?: unknown }) => ({ __config: config }),
redirect: (options: unknown) => ({ redirect: options }),
Link: "a",
useNavigate: () => vi.fn(),
}));
import { Route } from "../routes/search";
function runValidateSearch(search: Record<string, unknown>) {
function runBeforeLoad(
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean },
hostname = "clawdhub.com",
) {
const route = Route as unknown as {
__config: {
validateSearch?: (search: Record<string, unknown>) => unknown;
beforeLoad?: (args: {
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean };
location: { url: URL };
}) => void;
};
};
const validateSearch = route.__config.validateSearch;
return validateSearch ? validateSearch(search) : {};
const beforeLoad = route.__config.beforeLoad as (args: {
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean };
location: { url: URL };
}) => void;
let thrown: unknown;
try {
beforeLoad({ search, location: { url: new URL(`https://${hostname}/search`) } });
} catch (error) {
thrown = error;
}
return thrown;
}
describe("search route", () => {
it("validates search with query", () => {
expect(runValidateSearch({ q: "crab" })).toEqual({
q: "crab",
type: undefined,
it("redirects skills host to the skills index", () => {
expect(runBeforeLoad({ q: "crab", highlighted: true }, "clawdhub.com")).toEqual({
redirect: {
to: "/skills",
search: {
q: "crab",
sort: undefined,
dir: undefined,
highlighted: true,
nonSuspicious: undefined,
view: undefined,
},
replace: true,
},
});
});
it("validates search with type filter", () => {
expect(runValidateSearch({ q: "crab", type: "skills" })).toEqual({
q: "crab",
type: "skills",
it("forwards nonSuspicious filter to skills index", () => {
expect(runBeforeLoad({ q: "crab", nonSuspicious: true }, "clawdhub.com")).toEqual({
redirect: {
to: "/skills",
search: {
q: "crab",
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
},
replace: true,
},
});
});
it("ignores invalid type filter", () => {
expect(runValidateSearch({ q: "crab", type: "invalid" })).toEqual({
q: "crab",
type: undefined,
it("redirects souls host with query to home search", () => {
expect(runBeforeLoad({ q: "crab", highlighted: true }, "onlycrabs.ai")).toEqual({
redirect: {
to: "/",
search: {
q: "crab",
highlighted: undefined,
search: undefined,
},
replace: true,
},
});
});
it("accepts the users type filter", () => {
expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({
q: "vincent",
type: "users",
it("redirects souls host without query to home with search mode", () => {
expect(runBeforeLoad({}, "onlycrabs.ai")).toEqual({
redirect: {
to: "/",
search: {
q: undefined,
highlighted: undefined,
search: true,
},
replace: true,
},
});
});
it("strips empty query", () => {
expect(runValidateSearch({ q: " " })).toEqual({
q: undefined,
type: undefined,
});
});
it("has a component (not a redirect-only route)", () => {
const route = Route as unknown as {
__config: { component?: unknown };
};
expect(route.__config.component).toBeDefined();
});
});
-80
View File
@@ -1,80 +0,0 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from "@testing-library/react";
import type { ComponentType, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const navigateMock = vi.fn();
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" | "users" } = {};
vi.mock("@tanstack/react-router", () => ({
createFileRoute:
() =>
(config: { component?: unknown; validateSearch?: unknown }) => ({
__config: config,
useSearch: () => searchMock,
}),
useNavigate: () => navigateMock,
}));
vi.mock("../lib/useUnifiedSearch", () => ({
useUnifiedSearch: () => ({
results: [],
skillCount: 0,
pluginCount: 0,
userCount: 0,
isSearching: false,
}),
}));
vi.mock("../components/PluginListItem", () => ({
PluginListItem: ({ item }: { item: { name: string } }) => <div>{item.name}</div>,
}));
vi.mock("../components/SkillListItem", () => ({
SkillListItem: ({ skill }: { skill: { slug: string } }) => <div>{skill.slug}</div>,
}));
vi.mock("../components/UserListItem", () => ({
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
}));
vi.mock("../components/ui/card", () => ({
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
async function loadRoute() {
return (await import("../routes/search")).Route as unknown as {
__config: {
component?: ComponentType;
};
};
}
describe("search route", () => {
beforeEach(() => {
searchMock = { q: "first" };
navigateMock.mockReset();
});
it("keeps the input synced with query param changes while mounted", async () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
const rendered = render(<Component />);
const input = screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement;
expect(input.value).toBe("first");
fireEvent.change(input, { target: { value: "draft" } });
expect(input.value).toBe("draft");
searchMock = { q: "second" };
rendered.rerender(<Component />);
expect(
(screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement).value,
).toBe("second");
});
});
+194 -53
View File
@@ -6,18 +6,6 @@ import { SkillDetailPage } from "../components/SkillDetailPage";
const navigateMock = vi.fn();
const useAuthStatusMock = vi.fn();
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
vi.mock("../components/UserBadge", () => ({
UserBadge: () => null,
}));
vi.mock("../convex/client", () => ({
convex: {},
convexHttp: { query: vi.fn() },
}));
vi.mock("@tanstack/react-router", () => ({
Link: ({ children }: { children: unknown }) => children,
useNavigate: () => navigateMock,
@@ -27,7 +15,6 @@ const useQueryMock = vi.fn();
const getReadmeMock = vi.fn();
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => vi.fn(),
useAction: () => getReadmeMock,
@@ -37,8 +24,8 @@ vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
vi.mock("../components/SkillCommentsPanel", () => ({
SkillCommentsPanel: () => <div data-testid="skill-comments-panel" />,
vi.mock("../components/SkillDiffCard", () => ({
SkillDiffCard: () => <div data-testid="skill-diff-card" />,
}));
describe("SkillDetailPage", () => {
@@ -71,8 +58,11 @@ describe("SkillDetailPage", () => {
return undefined;
});
render(<SkillDetailPage slug="weather" />);
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
const { container } = render(<SkillDetailPage slug="weather" />);
// Loading state now renders a skeleton, not text
expect(
container.querySelector('[class*="animate-pulse"], [data-slot="skeleton"]'),
).toBeTruthy();
expect(screen.queryByText(/Skill not found/i)).toBeNull();
});
@@ -145,11 +135,174 @@ describe("SkillDetailPage", () => {
/>,
);
expect(screen.queryByText(/Loading skill/i)).toBeNull();
expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0);
// With initialData, should render content instead of skeleton
expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy();
expect(screen.getByText(/Get current weather\./i)).toBeTruthy();
expect(screen.getByRole("button", { name: "Files" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Compare" })).toBeNull();
expect(screen.getByRole("tab", { name: "Files" })).toBeTruthy();
});
it("shows capability tags on the skill page without other scan findings", async () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "skillId" in args) return [];
return undefined;
});
render(
<SkillDetailPage
slug="skill-pay"
initialData={{
result: {
skill: {
_id: skillId,
_creationTime: 0,
slug: "skill-pay",
displayName: "SkillPay",
summary: "Crypto payments for AI skills.",
ownerUserId: ownerId,
ownerPublisherId,
tags: {},
badges: {},
stats: {
stars: 12,
downloads: 34,
installsCurrent: 5,
installsAllTime: 8,
versions: 1,
comments: 0,
},
createdAt: 0,
updatedAt: 0,
},
owner: {
_id: ownerPublisherId,
_creationTime: 0,
kind: "user",
handle: "steipete",
displayName: "Peter",
linkedUserId: ownerId,
},
latestVersion: {
_id: versionId,
_creationTime: 0,
skillId,
version: "1.0.0",
fingerprint: "abc",
changelog: "Initial release",
parsed: { license: "MIT-0", frontmatter: {} },
capabilityTags: ["crypto", "requires-wallet", "can-make-purchases"],
sha256hash: "abc123",
files: [
{
path: "SKILL.md",
size: 10,
storageId,
sha256: "abc",
contentType: "text/markdown",
},
],
createdBy: ownerId,
createdAt: 0,
},
forkOf: null,
canonical: null,
},
readme: "# SkillPay",
readmeError: null,
}}
/>,
);
expect(await screen.findByRole("heading", { name: "SkillPay" })).toBeTruthy();
expect(screen.getByText("Capability signals")).toBeTruthy();
expect(screen.getByText("Crypto")).toBeTruthy();
expect(screen.getByText("Requires wallet")).toBeTruthy();
expect(screen.getByText("Can make purchases")).toBeTruthy();
});
it("prefers the full frontmatter description over the shortened summary in the header", async () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "skillId" in args) return [];
return undefined;
});
const fullDescription =
"Add credit-based payments to any OpenClaw skill. Register paid skills, charge users per call, track earnings, and withdraw USDC. Use when a user wants to monetize a skill.";
render(
<SkillDetailPage
slug="skill-pay"
initialData={{
result: {
skill: {
_id: skillId,
_creationTime: 0,
slug: "skill-pay",
displayName: "SkillPay",
summary: "Add credit-based payments to any OpenClaw skill. Register paid skills...",
ownerUserId: ownerId,
ownerPublisherId,
tags: {},
badges: {},
stats: {
stars: 12,
downloads: 34,
installsCurrent: 5,
installsAllTime: 8,
versions: 1,
comments: 0,
},
createdAt: 0,
updatedAt: 0,
},
owner: {
_id: ownerPublisherId,
_creationTime: 0,
kind: "user",
handle: "steipete",
displayName: "Peter",
linkedUserId: ownerId,
},
latestVersion: {
_id: versionId,
_creationTime: 0,
skillId,
version: "1.0.0",
fingerprint: "abc",
changelog: "Initial release",
parsed: {
license: "MIT-0",
frontmatter: {
description: fullDescription,
},
},
files: [
{
path: "SKILL.md",
size: 10,
storageId,
sha256: "abc",
contentType: "text/markdown",
},
],
createdBy: ownerId,
createdAt: 0,
},
forkOf: null,
canonical: null,
},
readme: "# SkillPay",
readmeError: null,
}}
/>,
);
expect(await screen.findByRole("heading", { name: "SkillPay" })).toBeTruthy();
// The header now always shows skill.summary (not frontmatter.description)
expect(
screen.getByText("Add credit-based payments to any OpenClaw skill. Register paid skills..."),
).toBeTruthy();
});
it("does not refetch readme when SSR data already matches the latest version", async () => {
@@ -222,7 +375,7 @@ describe("SkillDetailPage", () => {
/>,
);
expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0);
expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy();
expect(screen.getByText(/Get current weather\./i)).toBeTruthy();
expect(getReadmeMock).not.toHaveBeenCalled();
});
@@ -241,17 +394,17 @@ describe("SkillDetailPage", () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "skillId" in args) return [];
return {
skill: {
_id: "skills:1",
slug: "weather",
displayName: "Weather",
summary: "Get current weather.",
ownerUserId: "users:1",
ownerPublisherId: "publishers:steipete",
tags: {},
stats: { stars: 0, downloads: 0 },
},
return {
skill: {
_id: "skills:1",
slug: "weather",
displayName: "Weather",
summary: "Get current weather.",
ownerUserId: "users:1",
ownerPublisherId: "publishers:steipete",
tags: {},
stats: { stars: 0, downloads: 0 },
},
owner: {
_id: "publishers:steipete",
_creationTime: 0,
@@ -264,8 +417,9 @@ describe("SkillDetailPage", () => {
};
});
render(<SkillDetailPage slug="weather" redirectToCanonical />);
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
const { container } = render(<SkillDetailPage slug="weather" redirectToCanonical />);
// Loading state now renders a skeleton, not text
expect(container.querySelector('[class*="animate-pulse"]')).toBeTruthy();
await waitFor(() => {
expect(navigateMock).toHaveBeenCalled();
@@ -370,7 +524,7 @@ describe("SkillDetailPage", () => {
/>,
);
expect(screen.queryByText(/Loading skill/i)).toBeNull();
expect(screen.queryByText(/Skill not found/i)).toBeNull();
expect(screen.getAllByText("Weather").length).toBeGreaterThan(0);
expect(navigateMock).not.toHaveBeenCalled();
});
@@ -480,24 +634,10 @@ describe("SkillDetailPage", () => {
it("defers compare version query until compare tab is requested", async () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (
args &&
typeof args === "object" &&
"skillId" in args &&
"limit" in args &&
(args as { limit: number }).limit === 50
) {
return [
{ _id: "skillVersions:1", version: "1.0.0", files: [] },
{ _id: "skillVersions:2", version: "1.1.0", files: [] },
];
}
if (args && typeof args === "object" && "skillId" in args && "limit" in args) {
if ((args as { limit: number }).limit === 200) return [];
}
if (args && typeof args === "object" && "limit" in args) {
return [];
}
if (args && typeof args === "object" && "skillId" in args) return [];
if (args && typeof args === "object" && "slug" in args) {
return {
skill: {
@@ -526,7 +666,6 @@ describe("SkillDetailPage", () => {
render(<SkillDetailPage slug="weather" />);
expect(await screen.findByText("Weather")).toBeTruthy();
expect(screen.getByRole("button", { name: /compare/i })).toBeTruthy();
expect(
useQueryMock.mock.calls.some((call) => {
@@ -540,7 +679,9 @@ describe("SkillDetailPage", () => {
}),
).toBe(false);
fireEvent.click(screen.getByRole("button", { name: /compare/i }));
const compareTab = screen.getByRole("tab", { name: /compare/i });
fireEvent.mouseEnter(compareTab);
fireEvent.click(compareTab);
await waitFor(() => {
expect(
-22
View File
@@ -17,7 +17,6 @@ vi.mock("@tanstack/react-router", () => ({
createFileRoute:
() =>
(config: {
beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown;
loader?: (args: { params: { owner: string; slug: string } }) => Promise<unknown>;
component?: unknown;
head?: unknown;
@@ -32,7 +31,6 @@ vi.mock("../lib/skillPage", () => ({
async function loadRoute() {
return (await import("../routes/$owner/$slug")).Route as unknown as {
__config: {
beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown;
loader?: (args: { params: { owner: string; slug: string } }) => Promise<unknown>;
head?: (args: {
params: { owner: string; slug: string };
@@ -47,14 +45,6 @@ async function loadRoute() {
};
}
async function runBeforeLoad(params: { owner: string; slug: string }) {
const route = await loadRoute();
const beforeLoad = route.__config.beforeLoad as ((args: {
params: { owner: string; slug: string };
}) => unknown) | undefined;
return beforeLoad?.({ params });
}
async function runLoader(params: { owner: string; slug: string }) {
const route = await loadRoute();
const loader = route.__config.loader as (args: {
@@ -81,18 +71,6 @@ function runHead(
}
describe("skill route loader", () => {
it("allows numeric owner handles in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "123abc", slug: "weather" })).not.toThrow();
});
it("allows raw owner ids in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "users:abc123", slug: "weather" })).not.toThrow();
});
it("allows raw publisher ids in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "publishers:abc123", slug: "weather" })).not.toThrow();
});
beforeEach(() => {
fetchSkillPageDataMock.mockReset();
});
@@ -23,7 +23,6 @@ vi.mock("@tanstack/react-router", () => ({
}));
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useAction: (...args: unknown[]) => convexReactMocks.useAction(...args),
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
}));
+48 -16
View File
@@ -23,7 +23,6 @@ vi.mock("@tanstack/react-router", () => ({
}));
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useAction: (...args: unknown[]) => convexReactMocks.useAction(...args),
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
}));
@@ -67,7 +66,7 @@ describe("SkillsIndex", () => {
it("renders an empty state when no skills are returned", async () => {
render(<SkillsIndex />);
await act(async () => {});
expect(screen.getByText("No skills found")).toBeTruthy();
expect(screen.getByText("No skills match that filter")).toBeTruthy();
});
it("shows loading state before fetch completes", async () => {
@@ -75,9 +74,9 @@ describe("SkillsIndex", () => {
convexHttpMock.query.mockReturnValue(new Promise(() => {}));
render(<SkillsIndex />);
await act(async () => {});
// Results area shows skeleton or dash while loading
expect(screen.getByText("\u2014")).toBeTruthy();
expect(screen.queryByText("No skills found")).toBeNull();
// Header subtitle shows "Loading skills..."
expect(screen.getAllByText("Loading skills...").length).toBeGreaterThanOrEqual(1);
expect(screen.queryByText("No skills match that filter")).toBeNull();
});
it("shows empty state immediately when search returns no results", async () => {
@@ -92,8 +91,8 @@ describe("SkillsIndex", () => {
});
// Should show empty state, not loading
expect(screen.getByText("No skills found")).toBeTruthy();
expect(screen.queryByText(/Loading skills/)).toBeNull();
expect(screen.getByText("No skills match that filter")).toBeTruthy();
expect(screen.queryByText("Loading skills...")).toBeNull();
});
it("skips list fetch and calls search when query is set", async () => {
@@ -137,7 +136,7 @@ describe("SkillsIndex", () => {
render(<SkillsIndex />);
const input = screen.getByPlaceholderText("Search skills...");
const input = screen.getByPlaceholderText("Search skills by name, slug, or summary...");
await act(async () => {
fireEvent.change(input, { target: { value: "cli-design-framework" } });
await vi.runAllTimersAsync();
@@ -162,7 +161,7 @@ describe("SkillsIndex", () => {
render(<SkillsIndex />);
const input = screen.getByPlaceholderText("Search skills...");
const input = screen.getByPlaceholderText("Search skills by name, slug, or summary...");
await act(async () => {
fireEvent.change(input, { target: { value: "cli-design-framework" } });
await vi.runAllTimersAsync();
@@ -252,12 +251,9 @@ describe("SkillsIndex", () => {
await vi.runAllTimersAsync();
});
const titles = Array.from(
document.querySelectorAll(".skill-list-item-name"),
).map((node) => node.textContent);
expect(titles[0]).toBe("Older High Score");
expect(titles[1]).toBe("Newer Low Score");
const links = screen.getAllByRole("link");
expect(links[0]?.textContent).toContain("Older High Score");
expect(links[1]?.textContent).toContain("Newer Low Score");
});
it("passes nonSuspiciousOnly to list query when filter is active", async () => {
@@ -292,6 +288,42 @@ describe("SkillsIndex", () => {
);
});
it("passes capabilityTag to list query when tag filter is active", async () => {
searchMock = { tag: "crypto" };
render(<SkillsIndex />);
await act(async () => {});
expect(convexHttpMock.query).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
capabilityTag: "crypto",
}),
);
});
it("shows and clears the active capability tag filter", async () => {
searchMock = { tag: "crypto" };
render(<SkillsIndex />);
await act(async () => {});
const capabilityChip = screen.getByRole("button", { name: /crypto/i });
expect(capabilityChip).toBeTruthy();
await act(async () => {
fireEvent.click(capabilityChip);
});
expect(navigateMock).toHaveBeenCalled();
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
replace?: boolean;
search: (prev: Record<string, unknown>) => Record<string, unknown>;
};
expect(lastCall.replace).toBe(true);
expect(lastCall.search({ tag: "crypto" })).toEqual({
tag: undefined,
});
});
it("shows load-more button when more results are available", async () => {
vi.stubGlobal("IntersectionObserver", undefined);
convexHttpMock.query.mockResolvedValue({
@@ -324,7 +356,7 @@ describe("SkillsIndex", () => {
fireEvent.click(loadMoreButton);
});
expect(screen.getByText(/Loading/)).toBeTruthy();
expect(screen.getByRole("button", { name: "Load more" }).hasAttribute("disabled")).toBe(true);
});
});
-50
View File
@@ -1,50 +0,0 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { createRef, type ComponentProps } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { SkillsToolbar } from '../routes/skills/-SkillsToolbar';
function renderToolbar(overrides?: Partial<ComponentProps<typeof SkillsToolbar>>) {
return render(
<SkillsToolbar
searchInputRef={createRef<HTMLInputElement>()}
query=""
hasQuery={false}
sort="downloads"
dir="desc"
view="list"
highlightedOnly={false}
nonSuspiciousOnly={false}
capabilityTag={undefined}
onQueryChange={vi.fn()}
onToggleHighlighted={vi.fn()}
onToggleNonSuspicious={vi.fn()}
onCapabilityTagChange={vi.fn()}
onSortChange={vi.fn()}
onToggleDir={vi.fn()}
onToggleView={vi.fn()}
{...overrides}
/>,
);
}
describe('SkillsToolbar', () => {
it('keeps filter chips on a dark-mode surface', () => {
renderToolbar();
const staffPicksButton = screen.getByRole('button', { name: 'Staff Picks' });
expect(staffPicksButton.className).toContain('dark:bg-[rgba(14,28,37,0.84)]');
expect(staffPicksButton.className).toContain('dark:text-[rgba(245,238,232,0.88)]');
});
it('uses a readable active color treatment in dark mode', () => {
renderToolbar({ highlightedOnly: true });
const staffPicksButton = screen.getByRole('button', { name: 'Staff Picks' });
expect(staffPicksButton.getAttribute('aria-pressed')).toBe('true');
expect(staffPicksButton.className).toContain('dark:bg-[rgba(255,131,95,0.14)]');
expect(staffPicksButton.className).toContain('dark:text-[#ffd5c9]');
});
});
-1
View File
@@ -22,7 +22,6 @@ const useAuthStatusMock = vi.fn();
let useActionCallCount = 0;
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => generateUploadUrl,
useAction: () => {
+4 -7
View File
@@ -3,7 +3,6 @@ import { useEffect, useRef } from "react";
import { convex } from "../convex/client";
import { getUserFacingAuthError, normalizeAuthErrorMessage } from "../lib/authErrorMessage";
import { clearAuthError, setAuthError } from "../lib/useAuthError";
import { TooltipProvider } from "./ui/tooltip";
import { UserBootstrap } from "./UserBootstrap";
function getPendingAuthCode() {
@@ -83,12 +82,10 @@ export function AuthErrorHandler() {
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<ConvexAuthProvider client={convex} shouldHandleCode={false}>
<TooltipProvider delayDuration={400}>
<AuthCodeHandler />
<AuthErrorHandler />
<UserBootstrap />
{children}
</TooltipProvider>
<AuthCodeHandler />
<AuthErrorHandler />
<UserBootstrap />
{children}
</ConvexAuthProvider>
);
}
-120
View File
@@ -1,120 +0,0 @@
import {
Database,
GitBranch,
MessageSquare,
Package,
Plug,
Shield,
Wrench,
Zap,
} from "lucide-react";
import type { SkillCategory } from "../lib/categories";
type FilterItem = {
key: string;
label: string;
active: boolean;
};
type SortOption = {
value: string;
label: string;
};
type BrowseSidebarProps = {
categories?: SkillCategory[];
activeCategory?: string;
onCategoryChange?: (slug: string | undefined) => void;
sortOptions: SortOption[];
activeSort: string;
onSortChange: (value: string) => void;
filters: FilterItem[];
onFilterToggle: (key: string) => void;
};
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
"mcp-tools": <Plug size={15} />,
prompts: <MessageSquare size={15} />,
workflows: <GitBranch size={15} />,
"dev-tools": <Wrench size={15} />,
data: <Database size={15} />,
security: <Shield size={15} />,
automation: <Zap size={15} />,
other: <Package size={15} />,
};
export function BrowseSidebar({
categories,
activeCategory,
onCategoryChange,
sortOptions,
activeSort,
onSortChange,
filters,
onFilterToggle,
}: BrowseSidebarProps) {
return (
<aside className="browse-sidebar" aria-label="Browse filters">
<fieldset className="sidebar-section" role="radiogroup" aria-label="Sort order">
<legend className="sidebar-title">Sort by</legend>
{sortOptions.map((opt) => (
<button
key={opt.value}
className={`sidebar-option${activeSort === opt.value ? " is-active" : ""}`}
type="button"
role="radio"
aria-checked={activeSort === opt.value}
onClick={() => onSortChange(opt.value)}
>
{opt.label}
</button>
))}
</fieldset>
{categories && onCategoryChange ? (
<fieldset className="sidebar-section" role="radiogroup" aria-label="Category filter">
<legend className="sidebar-title">Categories</legend>
<button
className={`sidebar-option${!activeCategory ? " is-active" : ""}`}
type="button"
role="radio"
aria-checked={!activeCategory}
onClick={() => onCategoryChange(undefined)}
>
All
</button>
{categories.map((cat) => (
<button
key={cat.slug}
className={`sidebar-option${activeCategory === cat.slug ? " is-active" : ""}`}
type="button"
role="radio"
aria-checked={activeCategory === cat.slug}
onClick={() => onCategoryChange(cat.slug)}
>
<span className="sidebar-option-icon" aria-hidden="true">
{CATEGORY_ICONS[cat.slug]}
</span>
{cat.label}
</button>
))}
</fieldset>
) : null}
<fieldset className="sidebar-section" aria-label="Toggle filters">
<legend className="sidebar-title">Filters</legend>
{filters.map((f) => (
<label key={f.key} className="sidebar-checkbox">
<input
type="checkbox"
checked={f.active}
onChange={() => onFilterToggle(f.key)}
aria-label={f.label}
/>
<span>{f.label}</span>
</label>
))}
</fieldset>
</aside>
);
}
+11 -1
View File
@@ -69,7 +69,17 @@ function DeploymentDriftBannerContent() {
return (
<div
role="alert"
className="mx-auto mt-4 w-[min(1100px,calc(100vw-32px))] rounded-[14px] border border-status-warning-fg/40 bg-status-warning-bg px-4 py-3 text-[0.95rem] leading-[1.4] text-status-warning-fg"
style={{
margin: "16px auto 0",
width: "min(1100px, calc(100vw - 32px))",
border: "1px solid #f59e0b",
background: "#fff7ed",
color: "#9a3412",
borderRadius: "14px",
padding: "12px 16px",
fontSize: "0.95rem",
lineHeight: 1.4,
}}
>
Deploy mismatch detected. Frontend expects backend build <code>{drift.expectedBuildSha}</code>{" "}
but Convex reports <code>{drift.actualBuildSha}</code>.
+27 -28
View File
@@ -1,35 +1,34 @@
import { Link } from "@tanstack/react-router";
import { FOOTER_NAV_SECTIONS } from "../lib/nav-items";
import { getSiteName } from "../lib/site";
import { Separator } from "./ui/separator";
export function Footer() {
const siteName = getSiteName();
return (
<footer className="site-footer" role="contentinfo">
<div className="site-footer-inner">
<div className="footer-grid">
{FOOTER_NAV_SECTIONS.map((section) => (
<div key={section.title} className="footer-col">
<h4 className="footer-col-title">{section.title}</h4>
{section.items.filter((item) => item.featureFlag !== false).map((item) => {
if (item.kind === "link") {
return (
<Link key={item.label} to={item.to} search={item.search ?? {}}>
{item.label}
</Link>
);
}
if (item.kind === "external") {
return (
<a key={item.label} href={item.href} target="_blank" rel="noreferrer">
{item.label}
</a>
);
}
// kind === "text"
return <span key={item.label}>{item.label}</span>;
})}
</div>
))}
<footer className="mt-auto px-7 pb-8 pt-12">
<div className="mx-auto max-w-[1200px]">
<Separator className="mb-6" />
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[0.82rem] text-[color:var(--ink-soft)]">
<span className="font-semibold text-[color:var(--ink)]">{siteName}</span>
<FooterLink href="https://openclaw.ai">OpenClaw</FooterLink>
<FooterLink href="https://vercel.com">Vercel</FooterLink>
<FooterLink href="https://www.convex.dev">Convex</FooterLink>
<FooterLink href="https://github.com/openclaw/clawhub">Open source (MIT)</FooterLink>
<FooterLink href="https://steipete.me">Peter Steinberger</FooterLink>
</div>
</div>
</footer>
);
}
function FooterLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-[color:var(--ink-soft)] transition-colors duration-150 hover:text-[color:var(--ink)]"
>
{children}
</a>
);
}
+259 -345
View File
@@ -1,21 +1,16 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { Ghost, Github, Menu, Monitor, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { type ComponentType, useMemo, useRef, useState } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import { Link } from "@tanstack/react-router";
import { Menu, Monitor, Moon, Plus, Search, Sun } from "lucide-react";
import { useMemo, useRef } from "react";
import { gravatarUrl } from "../lib/gravatar";
import {
filterNavItems,
type NavIconName,
PRIMARY_NAV_ITEMS,
SECONDARY_NAV_ITEMS,
} from "../lib/nav-items";
import { isModerator } from "../lib/roles";
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
import { applyTheme, useThemeMode } from "../lib/theme";
import { startThemeTransition } from "../lib/theme-transition";
import { setAuthError, useAuthError } from "../lib/useAuthError";
import { useAuthError } from "../lib/useAuthError";
import { SignInButton } from "./SignInButton";
import { useAuthStatus } from "../lib/useAuthStatus";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Button } from "./ui/button";
import {
DropdownMenu,
@@ -24,390 +19,309 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "./ui/sheet";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet";
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?: string }>> = {
wrench: Wrench,
plug: Plug,
ghost: Ghost,
};
const THEME_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 } = useThemeMode();
const { signOut } = useAuthActions();
const { mode, setMode } = useThemeMode();
const toggleRef = useRef<HTMLDivElement | null>(null);
const siteMode = getSiteMode();
const siteName = useMemo(() => getSiteName(siteMode), [siteMode]);
const isSoulMode = siteMode === "souls";
const clawHubUrl = getClawHubSiteUrl();
const navigate = useNavigate();
const location = useLocation();
const avatar = me?.image ?? (me?.email ? gravatarUrl(me.email) : undefined);
const handle = me?.handle ?? me?.displayName ?? "user";
const initial = (me?.displayName ?? me?.name ?? handle).charAt(0).toUpperCase();
const isStaff = isModerator(me);
const hasResolvedUser = Boolean(me);
const navCtx = useMemo(
() => ({ isSoulMode, isAuthenticated: hasResolvedUser, isStaff }),
[hasResolvedUser, isSoulMode, isStaff],
);
const primaryItems = useMemo(() => filterNavItems(PRIMARY_NAV_ITEMS, navCtx), [navCtx]);
const secondaryItems = useMemo(() => filterNavItems(SECONDARY_NAV_ITEMS, navCtx), [navCtx]);
const { error: authError, clear: clearAuthError } = useAuthError();
const signInRedirectTo = getCurrentRelativeUrl();
const [navSearchQuery, setNavSearchQuery] = useState("");
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const 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 cycleThemeMode = () => {
const currentIndex = Math.max(0, THEME_MODE_SEQUENCE.indexOf(mode));
const nextMode = THEME_MODE_SEQUENCE[(currentIndex + 1) % THEME_MODE_SEQUENCE.length] ?? "system";
setThemeMode(nextMode);
};
const handleNavSearch = (e: React.FormEvent) => {
e.preventDefault();
const q = navSearchQuery.trim();
if (!q) return;
void navigate({
to: isSoulMode ? "/souls" : "/search",
search: isSoulMode
? {
q,
const navLinks = (
<>
{isSoulMode ? (
<a
href={clawHubUrl}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
ClawHub
</a>
) : null}
{isSoulMode ? (
<Link
to="/souls"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}
: { q, type: undefined },
});
setNavSearchQuery("");
setMobileSearchOpen(false);
};
}}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Souls
</Link>
) : (
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Skills
</Link>
)}
{isSoulMode ? null : (
<Link
to="/plugins"
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Plugins
</Link>
)}
<Link
to={isSoulMode ? "/souls" : "/skills"}
search={
isSoulMode
? { q: undefined, sort: undefined, dir: undefined, view: undefined, focus: "search" }
: {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: "search",
}
}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)] inline-flex items-center gap-1.5"
>
<Search className="h-3.5 w-3.5" />
Search
</Link>
{isSoulMode ? null : (
<Link
to="/about"
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
About
</Link>
)}
{me ? (
<Link
to="/stars"
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Stars
</Link>
) : null}
{isStaff ? (
<Link
to="/management"
search={{ skill: undefined }}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Management
</Link>
) : null}
</>
);
return (
<header className="navbar">
<div className="navbar-inner">
{/* Row 1: Brand + Search + Actions */}
<div className="navbar-top">
<div className="nav-mobile">
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<button
className="nav-mobile-trigger"
type="button"
aria-label="Open menu"
onClick={() => setMobileMenuOpen(true)}
>
<Menu className="h-4 w-4" aria-hidden="true" />
</button>
<SheetContent side="left" className="mobile-nav-sheet">
<SheetHeader className="pr-10">
<header className="sticky top-0 z-50 border-b border-[color:var(--line)] bg-[color:var(--nav-bg)] backdrop-blur-xl">
<div className="mx-auto flex h-16 max-w-[1200px] items-center justify-between gap-4 px-5">
{/* Brand */}
<Link
to="/"
search={{ q: undefined, highlighted: undefined, search: undefined }}
className="flex items-center gap-2.5 font-display text-lg font-bold text-[color:var(--ink)] no-underline transition-opacity hover:opacity-80"
>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] p-0.5">
<img
src="/clawd-logo.png"
alt=""
aria-hidden="true"
className="h-full w-full rounded-full object-cover"
/>
</span>
<span>{siteName}</span>
</Link>
{/* Desktop nav */}
<nav className="hidden items-center gap-6 md:flex">{navLinks}</nav>
{/* Actions */}
<div className="flex items-center gap-3">
{/* Publish CTA (desktop, authenticated) */}
{isAuthenticated && me && (
<Link
to="/publish-skill"
search={{ updateSlug: undefined }}
className="hidden sm:block"
>
<Button variant="primary" size="sm">
<Plus className="h-3.5 w-3.5" />
Publish
</Button>
</Link>
)}
{/* Mobile nav trigger */}
<div className="md:hidden">
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Open menu">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-72">
<SheetHeader>
<SheetTitle>{siteName}</SheetTitle>
<SheetDescription>
Browse sections, switch theme, and access account actions.
</SheetDescription>
</SheetHeader>
<div className="mobile-nav-section">
{isSoulMode ? (
<SheetClose asChild>
<a href={clawHubUrl} className="mobile-nav-link">
ClawHub
</a>
</SheetClose>
) : null}
{primaryItems.map((item) => (
<SheetClose key={item.to + item.label} asChild>
<Link to={item.to} search={item.search ?? {}} className="mobile-nav-link">
{item.label}
</Link>
</SheetClose>
))}
{secondaryItems.map((item) => (
<SheetClose key={item.to + item.label} asChild>
<Link to={item.to} search={item.search ?? {}} className="mobile-nav-link">
{item.label === "Management" ? "Manage" : item.label}
</Link>
</SheetClose>
))}
</div>
<div className="mobile-nav-section">
<div className="mobile-nav-section-title">Theme mode</div>
<button
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeMode("system");
setMobileMenuOpen(false);
<nav className="mt-6 flex flex-col gap-4">{navLinks}</nav>
{/* Mobile theme toggle */}
<div className="mt-6 flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-widest text-[color:var(--ink-soft)]">
Theme
</span>
<ToggleGroup
type="single"
value={mode}
onValueChange={(value) => {
if (!value) return;
setTheme(value as "system" | "light" | "dark");
}}
aria-label="Theme mode"
>
<Monitor className="h-4 w-4" aria-hidden="true" />
System
</button>
<button
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeMode("light");
setMobileMenuOpen(false);
}}
>
<Sun className="h-4 w-4" aria-hidden="true" />
Light
</button>
<button
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeMode("dark");
setMobileMenuOpen(false);
}}
>
<Moon className="h-4 w-4" aria-hidden="true" />
Dark
</button>
<ToggleGroupItem value="system" aria-label="System theme">
<Monitor className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
<ToggleGroupItem value="light" aria-label="Light theme">
<Sun className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark theme">
<Moon className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Mobile publish link */}
{isAuthenticated && me && (
<div className="mt-6">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Button variant="primary" className="w-full">
<Plus className="h-4 w-4" />
Publish Skill
</Button>
</Link>
</div>
)}
</SheetContent>
</Sheet>
</div>
<Link
to="/"
search={{ q: undefined, highlighted: undefined, search: undefined }}
className="brand"
>
{/* TODO: Re-introduce logo once new asset is ready */}
{/* <span className="brand-mark">
<img src="/clawd-logo.png" alt="" aria-hidden="true" className="brand-mark-image" />
</span> */}
<span className="brand-name brand-name-responsive">{siteName}</span>
</Link>
<form className="navbar-search" onSubmit={handleNavSearch} role="search" aria-label="Site search">
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="navbar-search-input"
type="search"
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
value={navSearchQuery}
onChange={(e) => setNavSearchQuery(e.target.value)}
aria-label="Search"
/>
</form>
<div className="nav-actions">
<button
className="navbar-search-mobile-trigger"
type="button"
aria-label="Search"
onClick={() => setMobileSearchOpen(!mobileSearchOpen)}
{/* Desktop theme toggle */}
<div className="theme-toggle hidden md:block" ref={toggleRef}>
<ToggleGroup
type="single"
value={mode}
onValueChange={(value) => {
if (!value) return;
setTheme(value as "system" | "light" | "dark");
}}
aria-label="Theme mode"
>
<Search size={18} aria-hidden="true" />
</button>
<div className="theme-toggle" ref={toggleRef}>
<div className="theme-cycle-group" aria-label="Theme controls">
<ToggleGroupItem value="system" aria-label="System theme">
<Monitor className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
<ToggleGroupItem value="light" aria-label="Light theme">
<Sun className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark theme">
<Moon className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* User menu / Sign in */}
{isAuthenticated && me ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="theme-cycle-button theme-cycle-button-mode"
onClick={cycleThemeMode}
aria-label={`Cycle theme mode. Current: ${mode}`}
title={`Theme mode: ${mode}`}
className="flex cursor-pointer items-center gap-2 rounded-full border border-[color:var(--line)] bg-[color:var(--surface)] px-2 py-1.5 text-sm font-semibold text-[color:var(--ink)] transition-colors hover:border-[color:var(--border-ui-hover)]"
>
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<ToggleGroup
className="theme-mode-toggle"
type="single"
value={mode}
onValueChange={(value) => {
if (!value) return;
setThemeMode(value as "system" | "light" | "dark");
}}
aria-label="Theme mode"
>
<ToggleGroupItem value="system" aria-label="System theme">
<Monitor className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">System</span>
</ToggleGroupItem>
<ToggleGroupItem value="light" aria-label="Light theme">
<Sun className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Light</span>
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark theme">
<Moon className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Dark</span>
</ToggleGroupItem>
</ToggleGroup>
</div>
{isAuthenticated && me ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="user-trigger" type="button">
{avatar ? (
<img src={avatar} alt={me.displayName ?? me.name ?? "User avatar"} />
) : (
<span className="user-menu-fallback">{initial}</span>
<Avatar className="h-7 w-7">
{avatar && (
<AvatarImage src={avatar} alt={me.displayName ?? me.name ?? "User avatar"} />
)}
<span className="mono">@{handle}</span>
<span className="user-menu-chevron"></span>
<AvatarFallback className="text-xs">{initial}</AvatarFallback>
</Avatar>
<span className="hidden font-mono text-xs sm:inline">@{handle}</span>
<span className="text-xs text-[color:var(--ink-soft)]"></span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to="/dashboard">Dashboard</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings">Settings</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void signOut()}>Sign out</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<>
{authError ? (
<div
className="flex items-center gap-1 text-[0.85rem] text-red-600 dark:text-red-400"
role="alert"
>
{authError}
<button
type="button"
onClick={clearAuthError}
aria-label="Dismiss"
className="ml-1 cursor-pointer border-none bg-transparent p-0.5 text-inherit opacity-70 hover:opacity-100"
>
&times;
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to="/dashboard">Dashboard</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings">Settings</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void signOut()}>Sign out</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<>
{authError ? (
<div className="error mr-2 text-[0.85rem]" role="alert">
{authError}{" "}
<button
type="button"
onClick={clearAuthError}
aria-label="Dismiss"
className="cursor-pointer border-none bg-transparent px-0.5 py-0 text-inherit"
>
&times;
</button>
</div>
) : null}
<Button
variant="primary"
size="sm"
type="button"
disabled={isLoading}
onClick={() => {
clearAuthError();
void signIn(
"github",
signInRedirectTo ? { redirectTo: signInRedirectTo } : undefined,
).catch((error) => {
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
});
}}
>
<Github size={16} aria-hidden="true" />
<span className="sign-in-label">Sign in</span>
<span className="sign-in-provider">with GitHub</span>
</Button>
</>
)}
</div>
</div>
) : null}
<SignInButton
variant="primary"
size="sm"
disabled={isLoading}
>
<span>Sign in</span>
<span className="hidden text-white/70 sm:inline">with GitHub</span>
</SignInButton>
</>
)}
</div>
{/* Mobile search bar (expandable) */}
{mobileSearchOpen ? (
<form className="navbar-search-mobile" onSubmit={handleNavSearch}>
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="navbar-search-input"
type="text"
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
value={navSearchQuery}
onChange={(e) => setNavSearchQuery(e.target.value)}
autoFocus
/>
</form>
) : null}
{/* Row 2: Content type tabs */}
<nav className="navbar-tabs" aria-label="Content types">
<div className="navbar-tabs-primary">
{isSoulMode ? (
<a href={clawHubUrl} className="navbar-tab">
ClawHub
</a>
) : null}
{primaryItems.map((item) => {
const Icon = item.icon ? NAV_ICONS[item.icon] : null;
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
location.pathname.startsWith(prefix)
);
return (
<Link
key={item.to + item.label}
to={item.to}
className="navbar-tab"
search={item.search ?? {}}
data-status={isActiveByPrefix ? "active" : undefined}
>
{Icon ? <Icon size={14} className="opacity-50" aria-hidden="true" /> : null}
{item.label}
</Link>
);
})}
</div>
<div className="navbar-tabs-secondary">
{secondaryItems.map((item) => {
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
location.pathname.startsWith(prefix)
);
return (
<Link
key={item.to + item.label}
to={item.to}
search={item.search ?? {}}
className="navbar-tab navbar-tab-secondary"
data-status={isActiveByPrefix ? "active" : undefined}
>
{item.label === "Management" ? "Manage" : item.label}
</Link>
);
})}
</div>
</nav>
</div>
</header>
);
}
function getCurrentRelativeUrl() {
if (typeof window === "undefined") return "/";
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
function getThemeModeIcon(mode: "system" | "light" | "dark") {
switch (mode) {
case "light":
return Sun;
case "dark":
return Moon;
case "system":
default:
return Monitor;
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ export function InstallSwitcher({ exampleSlug = "sonoscli" }: InstallSwitcherPro
type="button"
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
pm === entry.id
? "bg-accent text-accent-fg shadow-sm"
? "bg-[color:var(--accent)] text-white shadow-sm"
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
role="tab"
-63
View File
@@ -1,63 +0,0 @@
import { FileText, Package, Plug, User } from "lucide-react";
type MarketplaceIconProps = {
kind: "skill" | "plugin" | "soul" | "user";
label: string;
imageUrl?: string | null;
size?: "sm" | "md";
};
const TONES = [
{ accent: "oklch(0.63 0.16 42)", wash: "oklch(0.95 0.04 42)" },
{ accent: "oklch(0.61 0.15 168)", wash: "oklch(0.95 0.04 168)" },
{ accent: "oklch(0.59 0.14 236)", wash: "oklch(0.95 0.04 236)" },
{ accent: "oklch(0.66 0.13 92)", wash: "oklch(0.96 0.04 92)" },
] as const;
function hashTone(label: string) {
let sum = 0;
for (const char of label) sum += char.charCodeAt(0);
return TONES[sum % TONES.length] ?? TONES[0];
}
function getIcon(kind: MarketplaceIconProps["kind"]) {
switch (kind) {
case "plugin":
return Plug;
case "soul":
return FileText;
case "user":
return User;
default:
return Package;
}
}
export function MarketplaceIcon({
kind,
label,
imageUrl,
size = "sm",
}: MarketplaceIconProps) {
const Icon = getIcon(kind);
const tone = hashTone(label);
return (
<span
className={`marketplace-icon marketplace-icon-${size}`}
style={
{
"--marketplace-icon-accent": tone.accent,
"--marketplace-icon-wash": tone.wash,
} as React.CSSProperties
}
aria-hidden="true"
>
{imageUrl ? (
<img className="marketplace-icon-image" src={imageUrl} alt="" loading="lazy" />
) : (
<Icon className="marketplace-icon-glyph" strokeWidth={1.8} />
)}
</span>
);
}
+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>
.
-40
View File
@@ -1,40 +0,0 @@
import { Link } from "@tanstack/react-router";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Badge } from "./ui/badge";
import { familyLabel } from "../lib/packageLabels";
import type { PackageListItem } from "../lib/packageApi";
type PluginListItemProps = {
item: PackageListItem;
};
export function PluginListItem({ item }: PluginListItemProps) {
return (
<Link to="/plugins/$name" params={{ name: item.name }} className="skill-list-item" aria-label={`Plugin: ${item.displayName}`}>
<MarketplaceIcon kind="plugin" label={item.displayName} />
<div className="skill-list-item-body">
<div className="skill-list-item-main">
{item.ownerHandle ? (
<>
<span className="skill-list-item-owner">@{item.ownerHandle}</span>
<span className="skill-list-item-sep">/</span>
</>
) : null}
<span className="skill-list-item-name">{item.displayName}</span>
<Badge variant="compact">{familyLabel(item.family)}</Badge>
{item.isOfficial ? <Badge variant="accent">Verified</Badge> : null}
</div>
<p className="skill-list-item-summary">{item.summary ?? "Plugin package for agent workflows."}</p>
<div className="skill-list-item-meta">
<span className="skill-list-item-meta-item">Plugin</span>
{item.latestVersion ? (
<span className="skill-list-item-meta-item">v{item.latestVersion}</span>
) : null}
<span className="skill-list-item-meta-item">
{item.ownerHandle ? `@${item.ownerHandle}` : "community"}
</span>
</div>
</div>
</Link>
);
}
+28 -11
View File
@@ -1,8 +1,8 @@
import { Link } from "@tanstack/react-router";
import { ShieldCheck } from "lucide-react";
import type { ReactNode } from "react";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Badge } from "./ui/badge";
import type { PublicSkill } from "../lib/publicUser";
import { Badge } from "./ui/badge";
type SkillCardProps = {
skill: PublicSkill;
@@ -12,6 +12,7 @@ type SkillCardProps = {
summaryFallback: string;
meta: ReactNode;
href?: string;
verified?: boolean;
};
export function SkillCard({
@@ -22,6 +23,7 @@ export function SkillCard({
summaryFallback,
meta,
href,
verified,
}: SkillCardProps) {
const owner = encodeURIComponent(String(skill.ownerUserId));
const link = href ?? `/${owner}/${skill.slug}`;
@@ -29,28 +31,43 @@ export function SkillCard({
const hasTags = badges.length || chip || platformLabels?.length;
return (
<Link to={link} className="card skill-card">
<Link
to={link}
className="group flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[22px] no-underline transition-all duration-200 ease-out hover:-translate-y-0.5 hover:shadow-[0_12px_28px_rgba(29,26,23,0.12)] hover:border-[color:var(--border-ui-hover)]"
>
{hasTags ? (
<div className="skill-card-tags">
<div className="flex flex-wrap items-center gap-1.5">
{badges.map((label) => (
<Badge key={label}>
<Badge key={label} variant="default">
{label}
</Badge>
))}
{chip ? <Badge variant="accent">{chip}</Badge> : null}
{chip ? (
<Badge variant="accent" className="text-[0.72rem] px-2.5 py-0.5">
{chip}
</Badge>
) : null}
{platformLabels?.map((label) => (
<Badge key={label} variant="compact">
{label}
</Badge>
))}
{verified && (
<span className="inline-flex items-center gap-1 text-[0.72rem] font-semibold text-emerald-600 dark:text-emerald-400">
<ShieldCheck className="h-3.5 w-3.5" />
</span>
)}
</div>
) : null}
<div className="skill-card-header">
<MarketplaceIcon kind="skill" label={skill.displayName} size="md" />
<h3 className="skill-card-title">{skill.displayName}</h3>
<h3 className="font-display text-base font-bold leading-tight text-[color:var(--ink)] group-hover:text-[color:var(--accent)]">
{skill.displayName}
</h3>
<p className="line-clamp-2 text-sm leading-relaxed text-[color:var(--ink-soft)]">
{skill.summary ?? summaryFallback}
</p>
<div className="mt-auto flex flex-col gap-2 pt-1 text-[0.82rem] text-[color:var(--ink-soft)]">
{meta}
</div>
<p className="skill-card-summary">{skill.summary ?? summaryFallback}</p>
<div className="skill-card-footer">{meta}</div>
</Link>
);
}
+106 -111
View File
@@ -2,16 +2,19 @@ import { useNavigate } from "@tanstack/react-router";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { useAction, useMutation, useQuery } from "convex/react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { canManageSkill, isModerator } from "../lib/roles";
import { hasOwnProperty } from "../lib/hasOwnProperty";
import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage";
import { useAuthStatus } from "../lib/useAuthStatus";
import { Card } from "./ui/card";
import { ClientOnly } from "./ClientOnly";
import { EmptyState } from "./EmptyState";
import { Container } from "./layout/Container";
import { SkillDetailSkeleton } from "./skeletons/SkillDetailSkeleton";
import { SkillCommentsPanel } from "./SkillCommentsPanel";
import { SkillDetailTabs, type DetailTab } from "./SkillDetailTabs";
import { SkillMetadataSidebar } from "./SkillMetadataSidebar";
import { SkillDetailTabs } from "./SkillDetailTabs";
import {
buildSkillHref,
formatConfigSnippet,
@@ -22,6 +25,8 @@ import {
import { SkillHeader } from "./SkillHeader";
import { SkillOwnershipPanel } from "./SkillOwnershipPanel";
import { SkillReportDialog } from "./SkillReportDialog";
import { Card } from "./ui/card";
import { Skeleton } from "./ui/skeleton";
type SkillDetailPageProps = {
slug: string;
@@ -33,13 +38,11 @@ type SkillDetailPageProps = {
type SkillFile = Doc<"skillVersions">["files"][number];
function formatReportError(error: unknown) {
if (error && typeof error === "object" && "data" in error) {
if (hasOwnProperty(error, "data")) {
const data = (error as { data?: unknown }).data;
if (typeof data === "string" && data.trim()) return data.trim();
if (
data &&
typeof data === "object" &&
"message" in data &&
hasOwnProperty(data, "message") &&
typeof (data as { message?: unknown }).message === "string"
) {
const message = (data as { message?: string }).message?.trim();
@@ -95,7 +98,7 @@ export function SkillDetailPage({
);
const [tagName, setTagName] = useState("latest");
const [tagVersionId, setTagVersionId] = useState<Id<"skillVersions"> | "">("");
const [activeTab, setActiveTab] = useState<DetailTab>("readme");
const [activeTab, setActiveTab] = useState<"files" | "compare" | "versions">("files");
const [shouldPrefetchCompare, setShouldPrefetchCompare] = useState(false);
const [isReportDialogOpen, setIsReportDialogOpen] = useState(false);
const [reportReason, setReportReason] = useState("");
@@ -212,6 +215,7 @@ export function SkillDetailPage({
?.clawdis;
const osLabels = useMemo(() => formatOsList(clawdis?.os), [clawdis?.os]);
const nixPlugin = clawdis?.nix?.plugin;
const nixSystems = clawdis?.nix?.systems ?? [];
const nixSnippet = nixPlugin ? formatNixInstallSnippet(nixPlugin) : null;
const configRequirements = clawdis?.config;
const configExample = configRequirements?.example
@@ -295,10 +299,13 @@ export function SkillDetailPage({
const deleteTag = (tag: string) => {
if (!skill) return;
if (!window.confirm(`Delete tag "${tag}"?`)) return;
void deleteTags({
skillId: skill._id,
tags: [tag],
toast(`Delete tag "${tag}"?`, {
action: {
label: "Delete",
onClick: () => {
void deleteTags({ skillId: skill._id, tags: [tag] });
},
},
});
};
@@ -317,9 +324,9 @@ export function SkillDetailPage({
const submission = await reportSkill({ skillId: skill._id, reason: trimmedReason });
closeReportDialog();
if (submission.reported) {
window.alert("Thanks — your report has been submitted.");
toast.success("Thanks — your report has been submitted.");
} else {
window.alert("You have already reported this skill.");
toast.info("You have already reported this skill.");
}
} catch (error) {
console.error("Failed to report skill", error);
@@ -329,19 +336,19 @@ export function SkillDetailPage({
};
if (isLoadingSkill || wantsCanonicalRedirect) {
return (
<main className="section">
<Card>
<div className="loading-indicator">Loading skill</div>
</Card>
</main>
);
return <SkillDetailSkeleton />;
}
if (result === null || !skill) {
return (
<main className="section">
<Card>Skill not found.</Card>
<main className="py-10">
<Container>
<EmptyState
title="Skill not found"
description="The skill you're looking for doesn't exist or may have been removed."
action={{ label: "Browse skills", href: "/skills" }}
/>
</Container>
</main>
);
}
@@ -349,91 +356,80 @@ export function SkillDetailPage({
const tagEntries = Object.entries(skill.tags ?? {}) as Array<[string, Id<"skillVersions">]>;
return (
<main className="section">
<div className="skill-detail-stack">
<SkillHeader
skill={skill}
owner={owner}
ownerHandle={ownerHandle}
latestVersion={latestVersion}
modInfo={modInfo}
canManage={canManage}
isAuthenticated={isAuthenticated}
isStaff={isStaff}
isStarred={isStarred}
onToggleStar={() => void toggleStar({ skillId: skill._id })}
onOpenReport={openReportDialog}
forkOf={forkOf}
forkOfLabel={forkOfLabel}
forkOfHref={forkOfHref}
forkOfOwnerHandle={forkOfOwnerHandle}
canonical={canonical}
canonicalHref={canonicalHref}
canonicalOwnerHandle={canonicalOwnerHandle}
staffModerationNote={staffModerationNote}
staffVisibilityTag={staffVisibilityTag}
isAutoHidden={isAutoHidden}
isRemoved={isRemoved}
nixPlugin={nixPlugin}
hasPluginBundle={hasPluginBundle}
configRequirements={configRequirements}
cliHelp={cliHelp}
tagEntries={tagEntries}
versionById={versionById}
tagName={tagName}
onTagNameChange={setTagName}
tagVersionId={tagVersionId}
onTagVersionChange={setTagVersionId}
onTagSubmit={submitTag}
onTagDelete={deleteTag}
tagVersions={versions ?? []}
clawdis={clawdis}
osLabels={osLabels}
/>
{isOwner && skill ? (
<SkillOwnershipPanel
skillId={skill._id}
slug={skill.slug}
<main className="py-10">
<Container>
<div className="flex flex-col gap-6">
<SkillHeader
skill={skill}
owner={owner}
ownerHandle={ownerHandle}
ownerId={owner?._id ?? null}
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
latestVersion={latestVersion}
modInfo={modInfo}
canManage={canManage}
isAuthenticated={isAuthenticated}
isStaff={isStaff}
isStarred={isStarred}
onToggleStar={() => void toggleStar({ skillId: skill._id })}
onOpenReport={openReportDialog}
forkOf={forkOf}
forkOfLabel={forkOfLabel}
forkOfHref={forkOfHref}
forkOfOwnerHandle={forkOfOwnerHandle}
canonical={canonical}
canonicalHref={canonicalHref}
canonicalOwnerHandle={canonicalOwnerHandle}
staffModerationNote={staffModerationNote}
staffVisibilityTag={staffVisibilityTag}
isAutoHidden={isAutoHidden}
isRemoved={isRemoved}
nixPlugin={nixPlugin}
hasPluginBundle={hasPluginBundle}
configRequirements={configRequirements}
cliHelp={cliHelp}
tagEntries={tagEntries}
versionById={versionById}
tagName={tagName}
onTagNameChange={setTagName}
tagVersionId={tagVersionId}
onTagVersionChange={setTagVersionId}
onTagSubmit={submitTag}
onTagDelete={deleteTag}
tagVersions={versions ?? []}
clawdis={clawdis}
osLabels={osLabels}
/>
) : null}
<SkillMetadataSidebar
skill={skill}
latestVersion={latestVersion}
owner={owner}
ownerHandle={ownerHandle}
clawdis={clawdis}
osLabels={osLabels}
tagEntries={tagEntries}
isMalwareBlocked={modInfo?.isMalwareBlocked}
isRemoved={modInfo?.isRemoved}
nixPlugin={nixPlugin}
/>
{isOwner && skill ? (
<SkillOwnershipPanel
skillId={skill._id}
slug={skill.slug}
ownerHandle={ownerHandle}
ownerId={owner?._id ?? null}
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
/>
) : null}
<div className="detail-content-full">
{nixSnippet ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
Install via Nix
</h3>
<pre className="hero-install-code mt-2">
{nixSnippet}
</pre>
</h2>
<p className="text-sm text-[color:var(--ink-soft)]">
{nixSystems.length ? `Systems: ${nixSystems.join(", ")}` : "nix-clawdbot"}
</p>
<pre className="hero-install-code mt-3">{nixSnippet}</pre>
</Card>
) : null}
{configExample ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
Config example
</h3>
<pre className="hero-install-code mt-2">
{configExample}
</pre>
</h2>
<p className="text-sm text-[color:var(--ink-soft)]">
Starter config for this plugin bundle.
</p>
<pre className="hero-install-code mt-3">{configExample}</pre>
</Card>
) : null}
@@ -456,12 +452,11 @@ export function SkillDetailPage({
<ClientOnly
fallback={
<Card>
<h2 className="section-title text-[1.2rem] m-0">
Comments
</h2>
<p className="section-subtitle mt-3 mb-0">
Loading comments...
</p>
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Comments</h2>
<div className="flex flex-col gap-3 pt-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-20 w-full" />
</div>
</Card>
}
>
@@ -472,17 +467,17 @@ export function SkillDetailPage({
/>
</ClientOnly>
</div>
</div>
<SkillReportDialog
isOpen={isAuthenticated && isReportDialogOpen}
isSubmitting={isSubmittingReport}
reportReason={reportReason}
reportError={reportError}
onReasonChange={setReportReason}
onCancel={closeReportDialog}
onSubmit={() => void submitReport()}
/>
<SkillReportDialog
isOpen={isAuthenticated && isReportDialogOpen}
isSubmitting={isSubmittingReport}
reportReason={reportReason}
reportError={reportError}
onReasonChange={setReportReason}
onCancel={closeReportDialog}
onSubmit={() => void submitReport()}
/>
</Container>
</main>
);
}
+38 -83
View File
@@ -1,8 +1,9 @@
import { lazy, Suspense } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { SkillVersionsPanel } from "./SkillVersionsPanel";
import { Card } from "./ui/card";
import { Skeleton } from "./ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
const SkillDiffCard = lazy(() =>
import("./SkillDiffCard").then((module) => ({ default: module.SkillDiffCard })),
@@ -14,11 +15,9 @@ const SkillFilesPanel = lazy(() =>
type SkillFile = Doc<"skillVersions">["files"][number];
export type DetailTab = "readme" | "files" | "compare" | "versions";
type SkillDetailTabsProps = {
activeTab: DetailTab;
setActiveTab: (tab: DetailTab) => void;
activeTab: "files" | "compare" | "versions";
setActiveTab: (tab: "files" | "compare" | "versions") => void;
onCompareIntent: () => void;
readmeContent: string | null;
readmeError: string | null;
@@ -47,30 +46,13 @@ export function SkillDetailTabs({
suppressVersionScanResults,
scanResultsSuppressedMessage,
}: SkillDetailTabsProps) {
const compareEnabled = (versions?.length ?? 0) > 1;
return (
<div className="card tab-card">
<div className="tab-header">
<button
className={`tab-button${activeTab === "readme" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("readme")}
>
README
</button>
<button
className={`tab-button${activeTab === "files" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("files")}
>
Files
</button>
{compareEnabled ? (
<button
className={`tab-button${activeTab === "compare" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("compare")}
<Card>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as typeof activeTab)}>
<TabsList>
<TabsTrigger value="files">Files</TabsTrigger>
<TabsTrigger
value="compare"
onMouseEnter={() => {
onCompareIntent();
void import("./SkillDiffCard");
@@ -81,64 +63,37 @@ export function SkillDetailTabs({
}}
>
Compare
</button>
) : null}
<button
className={`tab-button${activeTab === "versions" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("versions")}
>
Versions
</button>
</div>
</TabsTrigger>
<TabsTrigger value="versions">Versions</TabsTrigger>
</TabsList>
{activeTab === "readme" ? (
<div className="tab-body">
{readmeContent ? (
<div className="markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readmeContent}</ReactMarkdown>
</div>
) : readmeError ? (
<div className="empty-state px-[var(--space-4)] py-[var(--space-6)]">
<p className="empty-state-title">No README available</p>
<p className="empty-state-body">
This skill doesn't have a SKILL.md file yet.
</p>
</div>
) : (
<div className="stat p-4">
Loading README...
</div>
)}
</div>
) : null}
<TabsContent value="files">
<Suspense fallback={<Skeleton className="h-40 w-full" />}>
<SkillFilesPanel
versionId={latestVersionId}
readmeContent={readmeContent}
readmeError={readmeError}
latestFiles={latestFiles}
/>
</Suspense>
</TabsContent>
{activeTab === "files" ? (
<Suspense fallback={<div className="tab-body stat">Loading file viewer...</div>}>
<SkillFilesPanel
versionId={latestVersionId}
latestFiles={latestFiles}
/>
</Suspense>
) : null}
{activeTab === "compare" ? (
<div className="tab-body">
<Suspense fallback={<div className="stat">Loading diff viewer...</div>}>
<TabsContent value="compare">
<Suspense fallback={<Skeleton className="h-40 w-full" />}>
<SkillDiffCard skill={skill} versions={diffVersions ?? []} variant="embedded" />
</Suspense>
</div>
) : null}
</TabsContent>
{activeTab === "versions" ? (
<SkillVersionsPanel
versions={versions}
nixPlugin={nixPlugin}
skillSlug={skill.slug}
suppressScanResults={suppressVersionScanResults}
suppressedMessage={scanResultsSuppressedMessage}
/>
) : null}
</div>
<TabsContent value="versions">
<SkillVersionsPanel
versions={versions}
nixPlugin={nixPlugin}
skillSlug={skill.slug}
suppressScanResults={suppressVersionScanResults}
suppressedMessage={scanResultsSuppressedMessage}
/>
</TabsContent>
</Tabs>
</Card>
);
}
+2 -2
View File
@@ -83,7 +83,7 @@ describe("SkillDiffCard", () => {
await waitFor(() => {
expect(screen.getByTestId("diff-editor").getAttribute("data-side-by-side")).toBe("false");
});
expect(screen.getByRole("button", { name: "Inline" }).className).toContain("is-active");
expect(screen.getByRole("button", { name: "Inline" }).className).toContain("shadow-sm");
expect(screen.getByTestId("diff-editor").getAttribute("data-inline-fallback")).toBe("false");
});
@@ -106,6 +106,6 @@ describe("SkillDiffCard", () => {
await waitFor(() => {
expect(screen.getByTestId("diff-editor").getAttribute("data-side-by-side")).toBe("true");
});
expect(screen.getByRole("button", { name: "Side-by-side" }).className).toContain("is-active");
expect(screen.getByRole("button", { name: "Side-by-side" }).className).toContain("shadow-sm");
});
});
+138 -109
View File
@@ -13,9 +13,12 @@ import {
selectDefaultFilePath,
sortVersionsBySemver,
} from "../lib/diffing";
import { isDarkThemeResolved, onThemeChange } from "../lib/theme";
import { Button } from "./ui/button";
import { ClientOnly } from "./ClientOnly";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card } from "./ui/card";
import { Label } from "./ui/label";
import { Skeleton } from "./ui/skeleton";
type SkillDiffCardProps = {
skill: Doc<"skills">;
@@ -233,18 +236,15 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
useEffect(() => {
if (!monaco || typeof document === "undefined") return;
const syncTheme = () => applyMonacoTheme(monaco);
const observer = new MutationObserver(syncTheme);
const observer = new MutationObserver(() => {
applyMonacoTheme(monaco);
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "data-theme-family", "data-theme-resolved"],
attributeFilter: ["data-theme"],
});
const removeThemeListener = onThemeChange(syncTheme);
syncTheme();
return () => {
observer.disconnect();
removeThemeListener();
};
applyMonacoTheme(monaco);
return () => observer.disconnect();
}, [monaco]);
useEffect(() => {
@@ -277,145 +277,174 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
const fileSelected = Boolean(selectedItem);
const diffOptions = useMemo(() => buildDiffOptions(viewMode), [viewMode]);
const containerClass = variant === "card" ? "card diff-card" : "diff-card diff-card-embedded";
const Wrapper = variant === "card" ? Card : "div";
const wrapperClassName = variant === "card" ? "flex flex-col gap-4" : "flex flex-col gap-4";
return (
<div className={containerClass}>
<div className="diff-header">
<Wrapper className={wrapperClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="section-title text-[1.2rem] m-0">
<h2 className="m-0 font-display text-[1.2rem] font-bold text-[color:var(--ink)]">
Compare versions
</h2>
<p className="section-subtitle m-0">
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
Inline or side-by-side diff for any file.
</p>
</div>
{!diffUnavailable ? (
<fieldset className="diff-toggle-group">
<legend className="sr-only">Diff layout</legend>
<button
className={`diff-toggle${viewMode === "split" ? " is-active" : ""}`}
type="button"
onClick={() => updateViewMode("split")}
>
Side-by-side
</button>
<button
className={`diff-toggle${viewMode === "inline" ? " is-active" : ""}`}
type="button"
onClick={() => updateViewMode("inline")}
>
Inline
</button>
</fieldset>
) : null}
<fieldset className="inline-flex items-center gap-0.5 rounded-full border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-[3px]">
<legend className="sr-only">Diff layout</legend>
<button
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
viewMode === "split"
? "bg-[color:var(--surface)] text-[color:var(--ink)] shadow-sm"
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
type="button"
onClick={() => updateViewMode("split")}
>
Side-by-side
</button>
<button
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
viewMode === "inline"
? "bg-[color:var(--surface)] text-[color:var(--ink)] shadow-sm"
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
type="button"
onClick={() => updateViewMode("inline")}
>
Inline
</button>
</fieldset>
</div>
{!diffUnavailable ? (
<>
<div className="diff-controls">
<div className="diff-select">
<label htmlFor="diff-left">Left</label>
<select
id="diff-left"
className="search-input"
value={leftVersionId ?? ""}
onChange={(event) => setLeftVersionId(event.target.value as Id<"skillVersions">)}
>
<option value="" disabled>
Select version
</option>
{renderOptions(versionOptions)}
</select>
</div>
<Button
className="diff-swap"
type="button"
onClick={() => {
setLeftVersionId(rightVersionId);
setRightVersionId(leftVersionId);
}}
disabled={!leftVersionId || !rightVersionId}
>
Swap
</Button>
<div className="diff-select">
<label htmlFor="diff-right">Right</label>
<select
id="diff-right"
className="search-input"
value={rightVersionId ?? ""}
onChange={(event) => setRightVersionId(event.target.value as Id<"skillVersions">)}
>
<option value="" disabled>
Select version
</option>
{renderOptions(versionOptions)}
</select>
</div>
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="flex min-w-[140px] flex-1 flex-col gap-1">
<Label htmlFor="diff-left">Left</Label>
<select
id="diff-left"
className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] focus:outline-none dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
value={leftVersionId ?? ""}
onChange={(event) => setLeftVersionId(event.target.value as Id<"skillVersions">)}
>
<option value="" disabled>
Select version
</option>
{renderOptions(versionOptions)}
</select>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setLeftVersionId(rightVersionId);
setRightVersionId(leftVersionId);
}}
disabled={!leftVersionId || !rightVersionId}
>
Swap
</Button>
<div className="flex min-w-[140px] flex-1 flex-col gap-1">
<Label htmlFor="diff-right">Right</Label>
<select
id="diff-right"
className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] focus:outline-none dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
value={rightVersionId ?? ""}
onChange={(event) => setRightVersionId(event.target.value as Id<"skillVersions">)}
>
<option value="" disabled>
Select version
</option>
{renderOptions(versionOptions)}
</select>
</div>
</div>
<div className="diff-meta">
<span>
Left {leftLabel} Right {rightLabel}
</span>
</div>
</>
) : null}
<div className="flex flex-wrap items-center gap-3 text-sm text-[color:var(--ink-soft)]">
<span>
Left {leftLabel} Right {rightLabel}
</span>
{diffUnavailable ? <span>Need at least 2 versions.</span> : null}
</div>
<div className="diff-layout">
<div className="diff-files">
{diffUnavailable ? (
<div className="diff-empty">
Publish another version to compare changes side by side.
</div>
) : fileDiffItems.length === 0 ? (
<div className="diff-empty">No files to compare.</div>
<div className="grid gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] md:grid-cols-[minmax(160px,220px)_1fr]">
<div className="flex max-h-[500px] flex-col overflow-y-auto border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] md:border-r md:border-b-0">
{fileDiffItems.length === 0 ? (
<div className="p-3 text-sm text-[color:var(--ink-soft)]">No files to compare.</div>
) : (
fileDiffItems.map((item) => (
<button
key={item.path}
type="button"
className={`diff-file${item.path === selectedPath ? " is-active" : ""}`}
className={`flex w-full cursor-pointer items-center gap-2 border-none px-3 py-2 text-left text-sm transition-colors hover:bg-[color:var(--surface)] ${
item.path === selectedPath
? "bg-[color:var(--surface)] font-semibold text-[color:var(--ink)]"
: "bg-transparent text-[color:var(--ink)]"
}`}
onClick={() => setSelectedPath(item.path)}
>
<span className={`diff-pill diff-pill-${item.status}`}>{item.status}</span>
<span className="diff-file-name">{item.path}</span>
<Badge
variant={
item.status === "added"
? "success"
: item.status === "removed"
? "destructive"
: item.status === "changed"
? "warning"
: "compact"
}
className="shrink-0 text-[0.65rem]"
>
{item.status}
</Badge>
<span className="truncate font-mono text-xs">{item.path}</span>
</button>
))
)}
</div>
<div className="diff-view">
<div className="relative min-h-[300px]">
{error ? (
<div className="diff-empty">{error}</div>
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
{error}
</div>
) : sizeWarning ? (
<div className="diff-empty">
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
{sizeWarning.side === "left" ? "Left" : "Right"} file exceeds 200KB:{" "}
{sizeWarning.path}
</div>
) : diffUnavailable ? (
<div className="diff-empty">Publish another version to compare.</div>
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
Publish another version to compare.
</div>
) : !selectionReady ? (
<div className="diff-empty">Select two versions to compare.</div>
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
Select two versions to compare.
</div>
) : !fileSelected ? (
<div className="diff-empty">Select a file to compare.</div>
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
Select a file to compare.
</div>
) : (
<ClientOnly fallback={<div className="diff-empty">Preparing diff</div>}>
<ClientOnly fallback={<Skeleton className="h-full w-full" />}>
<DiffEditor
key={`diff-${viewMode}`}
className={`diff-monaco diff-monaco-${viewMode}`}
className={`h-full min-h-[400px] w-full ${viewMode === "inline" ? "max-w-full" : ""}`}
original={leftText}
modified={rightText}
theme={getMonacoThemeName()}
loading={<div className="diff-empty">Loading diff</div>}
loading={<Skeleton className="h-full w-full" />}
options={diffOptions}
/>
{isLoading ? <div className="diff-loading">Loading</div> : null}
{isLoading ? (
<div className="absolute inset-0 flex items-center justify-center bg-[color:var(--surface)]/80">
<Skeleton className="h-8 w-24" />
</div>
) : null}
</ClientOnly>
)}
</div>
</div>
</div>
</Wrapper>
);
}
@@ -443,7 +472,7 @@ function renderOptions(options: VersionOption[]) {
function getMonacoThemeName() {
if (typeof document === "undefined") return "clawhub-light";
return isDarkThemeResolved() ? "clawhub-dark" : "clawhub-light";
return document.documentElement.dataset.theme === "dark" ? "clawhub-dark" : "clawhub-light";
}
function buildDiffOptions(viewMode: "split" | "inline"): DiffEditorProps["options"] {
@@ -479,7 +508,7 @@ function applyMonacoTheme(monaco: NonNullable<ReturnType<typeof useMonaco>>) {
const diffDiagonal = styles.getPropertyValue("--diff-diagonal").trim() || "#22222233";
const background = surface;
const gutter = surfaceMuted;
const isDark = isDarkThemeResolved();
const isDark = document.documentElement.dataset.theme === "dark";
const base = isDark ? "vs-dark" : "vs";
const diffInserted = withAlpha(diffAdded, isDark ? 0.22 : 0.2);
+8
View File
@@ -9,6 +9,10 @@ vi.mock("convex/react", () => ({
useAction: () => getFileTextMock,
}));
vi.mock("./MarkdownPreview", () => ({
MarkdownPreview: ({ children }: { children: string }) => <div>{children}</div>,
}));
type SkillFile = Doc<"skillVersions">["files"][number];
function makeFile(path: string, size: number): SkillFile {
@@ -30,6 +34,8 @@ describe("SkillFilesPanel", () => {
render(
<SkillFilesPanel
versionId={"skillVersions:1" as Id<"skillVersions">}
readmeContent={"# skill"}
readmeError={null}
latestFiles={[makeFile("scripts/run.sh", 10)]}
/>,
);
@@ -62,6 +68,8 @@ describe("SkillFilesPanel", () => {
render(
<SkillFilesPanel
versionId={"skillVersions:1" as Id<"skillVersions">}
readmeContent={"# skill"}
readmeError={null}
latestFiles={[makeFile("a.txt", 5), makeFile("b.txt", 6)]}
/>,
);
+53 -21
View File
@@ -2,17 +2,23 @@ import { useAction } from "convex/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { MarkdownPreview } from "./MarkdownPreview";
import { formatBytes } from "./skillDetailUtils";
import { Skeleton } from "./ui/skeleton";
type SkillFile = Doc<"skillVersions">["files"][number];
type SkillFilesPanelProps = {
versionId: Id<"skillVersions"> | null;
readmeContent: string | null;
readmeError: string | null;
latestFiles: SkillFile[];
};
export function SkillFilesPanel({
versionId,
readmeContent,
readmeError,
latestFiles,
}: SkillFilesPanelProps) {
const getFileText = useAction(api.skills.getFileText);
@@ -85,56 +91,82 @@ export function SkillFilesPanel({
);
return (
<div className="tab-body">
<div className="file-browser">
<div className="file-list">
<div className="file-list-header">
<h3 className="section-title text-[1.05rem] m-0">
<div className="grid max-w-full gap-5 overflow-x-auto">
<div>
<h2 className="m-0 font-display text-[1.2rem] font-bold text-[color:var(--ink)]">
SKILL.md
</h2>
<div>
{readmeContent ? (
<MarkdownPreview>{readmeContent}</MarkdownPreview>
) : readmeError ? (
<div className="text-sm text-[color:var(--ink-soft)]">
Failed to load SKILL.md: {readmeError}
</div>
) : (
<Skeleton className="h-24 w-full" />
)}
</div>
</div>
<div className="grid gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] md:grid-cols-[minmax(180px,280px)_1fr]">
<div className="flex flex-col border-b border-[color:var(--line)] md:border-r md:border-b-0">
<div className="flex items-center justify-between border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-2">
<h3 className="m-0 font-display text-[1.05rem] font-bold text-[color:var(--ink)]">
Files
</h3>
<span className="section-subtitle m-0">
<span className="m-0 text-sm text-[color:var(--ink-soft)]">
{latestFiles.length} total
</span>
</div>
<div className="file-list-body">
<div className="flex max-h-[400px] flex-col overflow-y-auto">
{latestFiles.length === 0 ? (
<div className="stat">No files available.</div>
<div className="px-3 py-2 text-sm text-[color:var(--ink-soft)]">
No files available.
</div>
) : (
latestFiles.map((file) => (
<button
key={file.path}
className={`file-row file-row-button${
selectedPath === file.path ? " is-active" : ""
className={`flex w-full cursor-pointer items-center justify-between border-none px-3 py-2 text-left text-sm transition-colors hover:bg-[color:var(--surface-muted)] ${
selectedPath === file.path
? "bg-[color:var(--surface-muted)] font-semibold text-[color:var(--ink)]"
: "bg-transparent text-[color:var(--ink)]"
}`}
type="button"
onClick={() => handleSelect(file.path)}
aria-current={selectedPath === file.path ? "true" : undefined}
>
<span className="file-path">{file.path}</span>
<span className="file-meta">{formatBytes(file.size)}</span>
<span className="truncate font-mono text-xs">{file.path}</span>
<span className="ml-2 shrink-0 text-xs text-[color:var(--ink-soft)]">
{formatBytes(file.size)}
</span>
</button>
))
)}
</div>
</div>
<div className="file-viewer">
<div className="file-viewer-header">
<div className="file-path">{selectedPath ?? "Select a file"}</div>
<div className="flex flex-col">
<div className="flex items-center justify-between border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-2">
<div className="truncate font-mono text-xs">{selectedPath ?? "Select a file"}</div>
{fileMeta ? (
<span className="file-meta">
<span className="ml-2 shrink-0 text-xs text-[color:var(--ink-soft)]">
{formatBytes(fileMeta.size)} · {fileMeta.sha256.slice(0, 12)}
</span>
) : null}
</div>
<div className="file-viewer-body">
<div className="min-h-[200px] p-3">
{isLoading ? (
<div className="stat">Loading</div>
<Skeleton className="h-24 w-full" />
) : fileError ? (
<div className="stat">Failed to load file: {fileError}</div>
<div className="text-sm text-[color:var(--ink-soft)]">
Failed to load file: {fileError}
</div>
) : fileContent ? (
<pre className="file-viewer-code">{fileContent}</pre>
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed">
{fileContent}
</pre>
) : (
<div className="stat">Select a file to preview.</div>
<div className="text-sm text-[color:var(--ink-soft)]">Select a file to preview.</div>
)}
</div>
</div>
+207 -156
View File
@@ -1,15 +1,21 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { Link } from "@tanstack/react-router";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_SUMMARY,
} from "clawhub-schema/licenseConstants";
import { Package } from "lucide-react";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { getSkillBadges } from "../lib/badges";
import { formatCompactStat, formatSkillStatsTriplet } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { type LlmAnalysis, SecurityScanResults } from "./SkillSecurityScanResults";
import { getRuntimeEnv } from "../lib/runtimeEnv";
import { SkillInstallCard } from "./SkillInstallCard";
import { type LlmAnalysis, SecurityScanResults } from "./SkillSecurityScanResults";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card, CardContent } from "./ui/card";
import { Input } from "./ui/input";
import { UserBadge } from "./UserBadge";
export type SkillModerationInfo = {
@@ -114,6 +120,7 @@ export function SkillHeader({
clawdis,
osLabels,
}: SkillHeaderProps) {
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
const formattedStats = formatSkillStatsTriplet(skill.stats);
const suppressScanResults =
!isStaff &&
@@ -127,8 +134,8 @@ export function SkillHeader({
return (
<>
{modInfo?.isPendingScan ? (
<div className="pending-banner">
<div className="pending-banner-content">
<div className="rounded-[var(--radius-md)] border border-amber-300/50 bg-amber-50 p-5 dark:border-amber-500/30 dark:bg-amber-950/40">
<div className="flex flex-col gap-2">
<strong>Security scan in progress</strong>
<p>
Your skill is being scanned by VirusTotal. It will be visible to others once the scan
@@ -138,8 +145,8 @@ export function SkillHeader({
</div>
</div>
) : modInfo?.isMalwareBlocked ? (
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<div className="rounded-[var(--radius-md)] border border-red-300/50 bg-red-50 p-5 dark:border-red-500/30 dark:bg-red-950/40">
<div className="flex flex-col gap-2">
<strong>Skill blocked malicious content detected</strong>
<p>
ClawHub Security flagged this skill as malicious. Downloads are disabled. Review the
@@ -148,15 +155,15 @@ export function SkillHeader({
</div>
</div>
) : modInfo?.isSuspicious ? (
<div className="pending-banner pending-banner-warning">
<div className="pending-banner-content">
<div className="rounded-[var(--radius-md)] border border-amber-300/50 bg-amber-50 p-5 dark:border-amber-500/30 dark:bg-amber-950/40">
<div className="flex flex-col gap-2">
<strong>Skill flagged suspicious patterns detected</strong>
<p>
ClawHub Security flagged this skill as suspicious. Review the scan results before
using.
</p>
{canManage ? (
<p className="pending-banner-appeal">
<p className="text-sm text-[color:var(--ink-soft)]">
If you believe this skill has been incorrectly flagged, please{" "}
<a
href="https://github.com/openclaw/clawhub/issues"
@@ -171,109 +178,142 @@ export function SkillHeader({
</div>
</div>
) : modInfo?.isRemoved ? (
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<div className="rounded-[var(--radius-md)] border border-red-300/50 bg-red-50 p-5 dark:border-red-500/30 dark:bg-red-950/40">
<div className="flex flex-col gap-2">
<strong>Skill removed by moderator</strong>
<p>This skill has been removed and is not visible to others.</p>
</div>
</div>
) : modInfo?.isHiddenByMod ? (
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<div className="rounded-[var(--radius-md)] border border-red-300/50 bg-red-50 p-5 dark:border-red-500/30 dark:bg-red-950/40">
<div className="flex flex-col gap-2">
<strong>Skill hidden</strong>
<p>This skill is currently hidden and not visible to others.</p>
</div>
</div>
) : null}
<div className="card skill-hero">
<div className={`skill-hero-top${hasPluginBundle ? " has-plugin" : ""}`}>
<div className="skill-hero-header">
<div className="skill-hero-title">
<div className="skill-hero-title-row">
<h1 className="section-title m-0">
{skill.displayName}
</h1>
{latestVersion?.version ? (
<span className="plugin-version-badge">v{latestVersion.version}</span>
) : null}
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
</div>
<p className="section-subtitle">{skill.summary ?? "No summary provided."}</p>
{isStaff && staffModerationNote ? (
<div className="skill-hero-note">{staffModerationNote}</div>
) : null}
{nixPlugin ? (
<div className="skill-hero-note">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
</div>
) : null}
<div className="skill-hero-inline-meta">
<div className="skill-hero-stats-row">
<span className="stat"> {formattedStats.stars}</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat"><Package size={14} aria-hidden="true" /> {formattedStats.downloads}</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">{formatCompactStat(skill.stats.installsCurrent ?? 0)} current</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">{formattedStats.installsAllTime} all-time</span>
</div>
<div className="skill-hero-meta-row">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix="by"
size="md"
showName
/>
{forkOf && forkOfHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
{forkOfLabel}{" "}
<a href={forkOfHref}>
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
{forkOf.skill.slug}
</a>
{forkOf.version ? ` (${forkOf.version})` : null}
</span>
</>
<Card>
<div className={`flex flex-col gap-5${hasPluginBundle ? " pb-2" : ""}`}>
<div className="flex flex-col gap-5 md:flex-row md:gap-8">
<div className="flex flex-1 flex-col gap-3">
<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
{skill.displayName}
</h1>
{latestVersion?.version ? (
<Badge variant="compact">v{latestVersion.version}</Badge>
) : null}
{canonicalHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
canonical:{" "}
<a href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
</>
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
</div>
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
{skill.summary ?? "No summary provided."}
</p>
{isStaff && staffModerationNote ? (
<div className="rounded-[var(--radius-sm)] border border-amber-200/60 bg-amber-50/60 px-3 py-2 text-sm text-[color:var(--ink-soft)] dark:border-amber-500/20 dark:bg-amber-950/30">
{staffModerationNote}
</div>
) : null}
{nixPlugin ? (
<div className="rounded-[var(--radius-sm)] border border-amber-200/60 bg-amber-50/60 px-3 py-2 text-sm text-[color:var(--ink-soft)] dark:border-amber-500/20 dark:bg-amber-950/30">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
</div>
) : null}
<div className="flex flex-col gap-2 pt-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-[color:var(--ink-soft)]">
{formattedStats.stars}
</span>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="flex items-center gap-1 text-sm text-[color:var(--ink-soft)]">
<Package size={14} aria-hidden="true" /> {formattedStats.downloads}
</span>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="text-sm text-[color:var(--ink-soft)]">
{formatCompactStat(skill.stats.installsCurrent ?? 0)} current
</span>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="text-sm text-[color:var(--ink-soft)]">
{formattedStats.installsAllTime} all-time
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix="by"
size="md"
showName
/>
{forkOf && forkOfHref ? (
<>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="text-sm text-[color:var(--ink-soft)]">
{forkOfLabel}{" "}
<a href={forkOfHref}>
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
{forkOf.skill.slug}
</a>
{forkOf.version ? ` (${forkOf.version})` : null}
</span>
</>
) : null}
{canonicalHref ? (
<>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="text-sm text-[color:var(--ink-soft)]">
canonical:{" "}
<a href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
</>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-1.5 pt-1">
<Badge variant="compact">{PLATFORM_SKILL_LICENSE}</Badge>
{getSkillBadges(skill).map((badge) => (
<Badge key={badge} variant="compact">
{badge}
</Badge>
))}
{isStaff && staffVisibilityTag ? (
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
{staffVisibilityTag}
</Badge>
) : null}
</div>
</div>
<div className="skill-hero-badges">
{getSkillBadges(skill).map((badge) => (
<Badge key={badge} variant="compact">
{badge}
</Badge>
))}
{isStaff && staffVisibilityTag ? (
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
{staffVisibilityTag}
</Badge>
) : null}
</div>
</div>
<div className="skill-hero-sidebar">
<div className="skill-actions">
<div className="flex w-full flex-col gap-3 md:w-[220px] md:shrink-0">
{!nixPlugin && !modInfo?.isMalwareBlocked && !modInfo?.isRemoved ? (
<a
href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}
className="inline-flex w-full items-center justify-center gap-2 whitespace-nowrap font-semibold text-sm min-h-[44px] rounded-[var(--radius-pill)] px-4 py-[11px] border-none bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] text-white transition-all duration-200 no-underline hover:-translate-y-px hover:shadow-[0_10px_20px_rgba(29,26,23,0.12)]"
>
Download zip
</a>
) : null}
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold text-[color:var(--ink-soft)]">
License
</span>
<span className="text-sm text-[color:var(--ink)]">
{PLATFORM_SKILL_LICENSE} · {PLATFORM_SKILL_LICENSE_SUMMARY}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{isAuthenticated ? (
<button
className={`star-toggle${isStarred ? " is-active" : ""}`}
className={`flex h-9 w-9 items-center justify-center rounded-full border transition-all duration-200 ${isStarred ? "border-amber-400/60 bg-amber-50 text-amber-500 dark:border-amber-500/40 dark:bg-amber-950/40" : "border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink-soft)] hover:text-amber-500"}`}
type="button"
onClick={onToggleStar}
aria-label={isStarred ? "Unstar skill" : "Star skill"}
@@ -282,16 +322,18 @@ export function SkillHeader({
</button>
) : null}
{isAuthenticated ? (
<Button variant="ghost" size="sm" type="button" onClick={onOpenReport}>
<Button variant="ghost" size="sm" onClick={onOpenReport}>
Report
</Button>
) : null}
{isStaff ? (
<Button asChild size="sm">
<Link to="/management" search={{ skill: skill.slug }}>
Manage
</Link>
</Button>
<Link
to="/management"
search={{ skill: skill.slug }}
className="inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold text-xs min-h-[34px] rounded-[var(--radius-pill)] px-3 py-1.5 border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] transition-all duration-200 no-underline"
>
Manage
</Link>
) : null}
</div>
</div>
@@ -299,12 +341,13 @@ export function SkillHeader({
{/* Security scan — full width below the header columns */}
{suppressScanResults ? (
<div className="skill-hero-note">{overrideScanMessage}</div>
<div className="rounded-[var(--radius-sm)] border border-amber-200/60 bg-amber-50/60 px-3 py-2 text-sm text-[color:var(--ink-soft)] dark:border-amber-500/20 dark:bg-amber-950/30">
{overrideScanMessage}
</div>
) : latestVersion?.sha256hash ||
latestVersion?.llmAnalysis ||
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ||
(latestVersion?.capabilityTags?.length ?? 0) > 0 ? (
<div className="skill-hero-scan-row">
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ? (
<div className="flex flex-col gap-2">
<SecurityScanResults
sha256hash={latestVersion?.sha256hash}
vtAnalysis={latestVersion?.vtAnalysis}
@@ -312,68 +355,77 @@ export function SkillHeader({
staticFindings={latestVersion?.staticScan?.findings}
capabilityTags={latestVersion?.capabilityTags}
/>
<p className="scan-disclaimer">
<p className="text-xs text-[color:var(--ink-soft)]">
Like a lobster shell, security has layers review code before you run it.
</p>
</div>
) : null}
{hasPluginBundle ? (
<div className="skill-panel bundle-card">
<div className="bundle-header">
<div className="bundle-title">Plugin bundle (nix)</div>
<div className="bundle-subtitle">Skill pack · CLI binary · Config</div>
</div>
<div className="bundle-includes">
<span>SKILL.md</span>
<span>CLI</span>
<span>Config</span>
</div>
{configRequirements ? (
<div className="bundle-section">
<div className="bundle-section-title">Config requirements</div>
<div className="bundle-meta">
{configRequirements.requiredEnv?.length ? (
<div className="stat">
<strong>Required env</strong>
<span>{configRequirements.requiredEnv.join(", ")}</span>
</div>
) : null}
{configRequirements.stateDirs?.length ? (
<div className="stat">
<strong>State dirs</strong>
<span>{configRequirements.stateDirs.join(", ")}</span>
</div>
) : null}
<Card className="border-dashed">
<CardContent>
<div className="flex flex-col gap-1">
<div className="font-display text-base font-bold text-[color:var(--ink)]">
Plugin bundle (nix)
</div>
<div className="text-sm text-[color:var(--ink-soft)]">
Skill pack · CLI binary · Config
</div>
</div>
) : null}
{cliHelp ? (
<details className="bundle-section bundle-details">
<summary>CLI help (from plugin)</summary>
<pre className="hero-install-code mono">{cliHelp}</pre>
</details>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Badge>SKILL.md</Badge>
<Badge>CLI</Badge>
<Badge>Config</Badge>
</div>
{configRequirements ? (
<div className="flex flex-col gap-2">
<div className="text-sm font-semibold text-[color:var(--ink)]">
Config requirements
</div>
<div className="flex flex-col gap-1">
{configRequirements.requiredEnv?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Required env</strong>
<span>{configRequirements.requiredEnv.join(", ")}</span>
</div>
) : null}
{configRequirements.stateDirs?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>State dirs</strong>
<span>{configRequirements.stateDirs.join(", ")}</span>
</div>
) : null}
</div>
</div>
) : null}
{cliHelp ? (
<details className="flex flex-col gap-2">
<summary className="cursor-pointer text-sm font-semibold text-[color:var(--ink)]">
CLI help (from plugin)
</summary>
<pre className="mt-2 overflow-x-auto rounded-[var(--radius-sm)] bg-[color:var(--surface-muted)] p-3 font-mono text-xs">
{cliHelp}
</pre>
</details>
) : null}
</CardContent>
</Card>
) : null}
<SkillInstallCard clawdis={clawdis} osLabels={osLabels} />
</div>
<div className="skill-tag-row">
<div className="flex flex-wrap items-center gap-2 border-t border-[color:var(--line)] pt-4">
{tagEntries.length === 0 ? (
<span className="section-subtitle m-0">
No tags yet.
</span>
<span className="m-0 text-sm text-[color:var(--ink-soft)]">No tags yet.</span>
) : (
tagEntries.map(([tag, versionId]) => (
<Badge key={tag}>
<Badge key={tag} className="gap-1.5">
{tag}
<span className="tag-meta">
<span className="text-[0.68rem] opacity-70">
v{versionById.get(versionId)?.version ?? versionId}
</span>
{canManage && tag !== "latest" ? (
<button
type="button"
className="tag-delete"
className="ml-0.5 cursor-pointer border-none bg-transparent p-0 text-current opacity-60 hover:opacity-100"
onClick={() => onTagDelete(tag)}
aria-label={`Delete tag ${tag}`}
title={`Delete tag "${tag}"`}
@@ -392,16 +444,16 @@ export function SkillHeader({
event.preventDefault();
onTagSubmit();
}}
className="tag-form"
className="flex flex-wrap items-end gap-2 border-t border-[color:var(--line)] pt-4"
>
<input
className="search-input"
<Input
value={tagName}
onChange={(event) => onTagNameChange(event.target.value)}
placeholder="latest"
className="w-auto max-w-[160px]"
/>
<select
className="search-input"
className="min-h-[44px] rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] focus:outline-none dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
value={tagVersionId ?? ""}
onChange={(event) => onTagVersionChange(event.target.value as Id<"skillVersions">)}
>
@@ -411,13 +463,12 @@ export function SkillHeader({
</option>
))}
</select>
<Button type="submit">
Update tag
</Button>
<Button type="submit">Update tag</Button>
</form>
) : null}
</div>
<SkillInstallCard clawdis={clawdis} osLabels={osLabels} />
</Card>
</>
);
}
+217 -162
View File
@@ -1,6 +1,12 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { Badge } from "./ui/badge";
import {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_SUMMARY,
PLATFORM_SKILL_LICENSE_URL,
} from "clawhub-schema/licenseConstants";
import { formatInstallCommand, formatInstallLabel } from "./skillDetailUtils";
import { Badge } from "./ui/badge";
import { Card, CardContent } from "./ui/card";
type SkillInstallCardProps = {
clawdis: ClawdisSkillMetadata | undefined;
@@ -26,180 +32,229 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
const hasInstallSpecs = installSpecs.length > 0;
const hasDependencies = dependencies.length > 0;
const hasLinks = Boolean(links?.homepage || links?.repository || links?.documentation);
const hasLicense = true;
if (!hasRuntimeRequirements && !hasInstallSpecs && !hasDependencies && !hasLinks) {
if (!hasRuntimeRequirements && !hasInstallSpecs && !hasDependencies && !hasLinks && !hasLicense) {
return null;
}
return (
<div className="skill-hero-content">
<div className="skill-hero-panels">
<div className="border-t border-[color:var(--line)] pt-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
License
</h3>
<div className="flex flex-col gap-2">
<Badge variant="accent">{PLATFORM_SKILL_LICENSE}</Badge>
<div className="text-sm text-[color:var(--ink-soft)]">
<span>{PLATFORM_SKILL_LICENSE_SUMMARY}</span>
</div>
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Terms</strong>
<a
href={PLATFORM_SKILL_LICENSE_URL}
target="_blank"
rel="noopener noreferrer"
className="ml-1"
>
{PLATFORM_SKILL_LICENSE_URL}
</a>
</div>
</div>
</CardContent>
</Card>
{hasRuntimeRequirements ? (
<div className="skill-panel">
<h3 className="section-title text-[1rem] m-0">
Runtime requirements
</h3>
<div className="skill-panel-body">
{clawdis?.emoji ? <Badge>{clawdis.emoji} Clawdis</Badge> : null}
{osLabels.length ? (
<div className="stat">
<strong>OS</strong>
<span>{osLabels.join(" · ")}</span>
</div>
) : null}
{requirements?.bins?.length ? (
<div className="stat">
<strong>Bins</strong>
<span>{requirements.bins.join(", ")}</span>
</div>
) : null}
{requirements?.anyBins?.length ? (
<div className="stat">
<strong>Any bin</strong>
<span>{requirements.anyBins.join(", ")}</span>
</div>
) : null}
{requirements?.env?.length ? (
<div className="stat">
<strong>Env</strong>
<span>{requirements.env.join(", ")}</span>
</div>
) : null}
{requirements?.config?.length ? (
<div className="stat">
<strong>Config</strong>
<span>{requirements.config.join(", ")}</span>
</div>
) : null}
{clawdis?.primaryEnv ? (
<div className="stat">
<strong>Primary env</strong>
<span>{clawdis.primaryEnv}</span>
</div>
) : null}
{envVars.length > 0 ? (
<div className="stat">
<strong>Environment variables</strong>
<div className="flex flex-col gap-1 mt-1">
{envVars.map((env, index) => (
<div
key={`${env.name}-${index}`}
className="flex items-baseline gap-2"
>
<code className="text-[0.85rem]">{env.name}</code>
{env.required === false ? (
<span className="text-ink-soft text-[0.75rem]">
optional
</span>
) : env.required === true ? (
<span className="text-ink-accent text-[0.75rem]">
required
</span>
) : null}
{env.description ? (
<span className="text-ink-soft text-[0.8rem]">
{env.description}
</span>
) : null}
</div>
))}
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
Runtime requirements
</h3>
<div className="flex flex-col gap-2">
{clawdis?.emoji ? <Badge>{clawdis.emoji} Clawdis</Badge> : null}
{osLabels.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>OS</strong>
<span className="ml-1">{osLabels.join(" · ")}</span>
</div>
</div>
) : null}
</div>
</div>
) : null}
{hasDependencies ? (
<div className="skill-panel">
<h3 className="section-title text-[1rem] m-0">
Dependencies
</h3>
<div className="skill-panel-body">
{dependencies.map((dep, index) => (
<div key={`${dep.name}-${index}`} className="stat">
<div>
<strong>{dep.name}</strong>
<span className="text-ink-soft text-[0.85rem] ml-2">
{dep.type}
{dep.version ? ` ${dep.version}` : ""}
</span>
{dep.url ? (
<div className="text-[0.8rem] break-all">
<a href={dep.url} target="_blank" rel="noopener noreferrer">
{dep.url}
</a>
</div>
) : null}
{dep.repository && dep.repository !== dep.url ? (
<div className="text-[0.8rem]">
<a href={dep.repository} target="_blank" rel="noopener noreferrer">
Source
</a>
</div>
) : null}
) : null}
{requirements?.bins?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Bins</strong>
<span className="ml-1">{requirements.bins.join(", ")}</span>
</div>
</div>
))}
</div>
</div>
) : null}
{hasInstallSpecs ? (
<div className="skill-panel">
<h3 className="section-title text-[1rem] m-0">
Install
</h3>
<div className="skill-panel-body">
{installSpecs.map((spec, index) => {
const command = formatInstallCommand(spec);
return (
<div key={`${spec.id ?? spec.kind}-${index}`} className="stat">
<div>
<strong>{spec.label ?? formatInstallLabel(spec)}</strong>
{spec.bins?.length ? (
<div className="text-ink-soft text-[0.85rem]">
Bins: {spec.bins.join(", ")}
) : null}
{requirements?.anyBins?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Any bin</strong>
<span className="ml-1">{requirements.anyBins.join(", ")}</span>
</div>
) : null}
{requirements?.env?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Env</strong>
<span className="ml-1">{requirements.env.join(", ")}</span>
</div>
) : null}
{requirements?.config?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Config</strong>
<span className="ml-1">{requirements.config.join(", ")}</span>
</div>
) : null}
{clawdis?.primaryEnv ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Primary env</strong>
<span className="ml-1">{clawdis.primaryEnv}</span>
</div>
) : null}
{envVars.length > 0 ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Environment variables</strong>
<div className="mt-1 flex flex-col gap-1">
{envVars.map((env, index) => (
<div key={`${env.name}-${index}`} className="flex items-baseline gap-2">
<code className="text-[0.85rem]">{env.name}</code>
{env.required === false ? (
<span className="text-xs text-[color:var(--ink-soft)]">optional</span>
) : env.required === true ? (
<span className="text-xs text-[color:var(--accent)]">required</span>
) : null}
{env.description ? (
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
{env.description}
</span>
) : null}
</div>
) : null}
{command ? <code>{command}</code> : null}
))}
</div>
</div>
);
})}
</div>
</div>
) : null}
</div>
</CardContent>
</Card>
) : null}
{hasDependencies ? (
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
Dependencies
</h3>
<div className="flex flex-col gap-2">
{dependencies.map((dep, index) => (
<div
key={`${dep.name}-${index}`}
className="text-sm text-[color:var(--ink-soft)]"
>
<div>
<strong>{dep.name}</strong>
<span className="ml-2 text-[0.85rem] text-[color:var(--ink-soft)]">
{dep.type}
{dep.version ? ` ${dep.version}` : ""}
</span>
{dep.url ? (
<div className="break-all text-[0.8rem]">
<a href={dep.url} target="_blank" rel="noopener noreferrer">
{dep.url}
</a>
</div>
) : null}
{dep.repository && dep.repository !== dep.url ? (
<div className="text-[0.8rem]">
<a href={dep.repository} target="_blank" rel="noopener noreferrer">
Source
</a>
</div>
) : null}
</div>
</div>
))}
</div>
</CardContent>
</Card>
) : null}
{hasInstallSpecs ? (
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
Install
</h3>
<div className="flex flex-col gap-2">
{installSpecs.map((spec, index) => {
const command = formatInstallCommand(spec);
return (
<div
key={`${spec.id ?? spec.kind}-${index}`}
className="text-sm text-[color:var(--ink-soft)]"
>
<div>
<strong>{spec.label ?? formatInstallLabel(spec)}</strong>
{spec.bins?.length ? (
<div className="text-[0.85rem] text-[color:var(--ink-soft)]">
Bins: {spec.bins.join(", ")}
</div>
) : null}
{command ? (
<code className="mt-0.5 block font-mono text-xs">{command}</code>
) : null}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
) : null}
{hasLinks ? (
<div className="skill-panel">
<h3 className="section-title text-[1rem] m-0">
Links
</h3>
<div className="skill-panel-body">
{links?.homepage ? (
<div className="stat">
<strong>Homepage</strong>
<a href={links.homepage} target="_blank" rel="noopener noreferrer" className="break-all">
{links.homepage}
</a>
</div>
) : null}
{links?.repository ? (
<div className="stat">
<strong>Repository</strong>
<a href={links.repository} target="_blank" rel="noopener noreferrer" className="break-all">
{links.repository}
</a>
</div>
) : null}
{links?.documentation ? (
<div className="stat">
<strong>Docs</strong>
<a href={links.documentation} target="_blank" rel="noopener noreferrer">
{links.documentation}
</a>
</div>
) : null}
</div>
</div>
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
Links
</h3>
<div className="flex flex-col gap-2">
{links?.homepage ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Homepage</strong>
<a
href={links.homepage}
target="_blank"
rel="noopener noreferrer"
className="ml-1 break-all"
>
{links.homepage}
</a>
</div>
) : null}
{links?.repository ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Repository</strong>
<a
href={links.repository}
target="_blank"
rel="noopener noreferrer"
className="ml-1 break-all"
>
{links.repository}
</a>
</div>
) : null}
{links?.documentation ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Docs</strong>
<a
href={links.documentation}
target="_blank"
rel="noopener noreferrer"
className="ml-1"
>
{links.documentation}
</a>
</div>
) : null}
</div>
</CardContent>
</Card>
) : null}
</div>
</div>
-53
View File
@@ -1,53 +0,0 @@
import { Link } from "@tanstack/react-router";
import { Package, Star } from "lucide-react";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Badge } from "./ui/badge";
import { getSkillBadges } from "../lib/badges";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { timeAgo } from "../lib/timeAgo";
type SkillListItemProps = {
skill: PublicSkill;
ownerHandle?: string | null;
owner?: PublicPublisher | null;
};
export function SkillListItem({ skill, ownerHandle, owner }: SkillListItemProps) {
const handle = ownerHandle ?? owner?.handle ?? null;
const ownerSegment = handle?.trim() || String(skill.ownerPublisherId ?? skill.ownerUserId);
const href = `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(skill.slug)}`;
const badges = getSkillBadges(skill);
return (
<Link to={href} className="skill-list-item">
<MarketplaceIcon kind="skill" label={skill.displayName} />
<div className="skill-list-item-body">
<div className="skill-list-item-main">
{handle ? (
<>
<span className="skill-list-item-owner">@{handle}</span>
<span className="skill-list-item-sep">/</span>
</>
) : null}
<span className="skill-list-item-name">{skill.displayName}</span>
{badges.map((b) => (
<Badge key={b} variant="compact">
{b}
</Badge>
))}
</div>
{skill.summary ? <p className="skill-list-item-summary">{skill.summary}</p> : null}
<div className="skill-list-item-meta">
<span className="skill-list-item-meta-item">Updated {timeAgo(skill.updatedAt)}</span>
<span className="skill-list-item-meta-item">
<Star size={14} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
</span>
<span className="skill-list-item-meta-item">
<Package size={14} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
</span>
</div>
</div>
</Link>
);
}
-121
View File
@@ -1,121 +0,0 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_SUMMARY,
} from "clawhub-schema/licenseConstants";
import { Calendar, Download, Package, Scale, Star, Tag } from "lucide-react";
import type { Id } from "../../convex/_generated/dataModel";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { getRuntimeEnv } from "../lib/runtimeEnv";
import { timeAgo } from "../lib/timeAgo";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { UserBadge } from "./UserBadge";
type SkillMetadataSidebarProps = {
skill: PublicSkill;
latestVersion: { version?: string; _id: Id<"skillVersions"> } | null;
owner: PublicPublisher | null;
ownerHandle: string | null;
clawdis?: ClawdisSkillMetadata;
osLabels: string[];
tagEntries: Array<[string, Id<"skillVersions">]>;
isMalwareBlocked?: boolean;
isRemoved?: boolean;
nixPlugin?: string;
};
export function SkillMetadataSidebar({
skill,
latestVersion,
owner,
ownerHandle,
clawdis: _clawdis,
osLabels,
tagEntries,
isMalwareBlocked,
isRemoved,
nixPlugin,
}: SkillMetadataSidebarProps) {
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
const showDownload = !nixPlugin && !isMalwareBlocked && !isRemoved;
return (
<div className="detail-meta-bar">
{/* Stats row */}
<div className="meta-bar-stats">
<div className="meta-stat">
<Download size={14} aria-hidden="true" />
<span className="meta-stat-value">{formatCompactStat(skill.stats.downloads)}</span>
<span className="meta-stat-label">downloads</span>
</div>
<div className="meta-stat">
<Star size={14} aria-hidden="true" />
<span className="meta-stat-value">{formatCompactStat(skill.stats.stars)}</span>
<span className="meta-stat-label">stars</span>
</div>
<div className="meta-stat">
<Package size={14} aria-hidden="true" />
<span className="meta-stat-value">{formatCompactStat(skill.stats.versions ?? 0)}</span>
<span className="meta-stat-label">versions</span>
</div>
</div>
{/* Details row */}
<div className="meta-bar-details">
<div className="meta-detail">
<Calendar size={12} aria-hidden="true" />
<span>Updated {timeAgo(skill.updatedAt)}</span>
</div>
{latestVersion?.version ? (
<div className="meta-detail">
<Tag size={12} aria-hidden="true" />
<span>v{latestVersion.version}</span>
</div>
) : null}
<div className="meta-detail">
<Scale size={12} aria-hidden="true" />
<span>{PLATFORM_SKILL_LICENSE}</span>
</div>
{osLabels.length > 0 ? (
<div className="meta-detail">
<span>{osLabels.join(", ")}</span>
</div>
) : null}
</div>
{/* Tags and Publisher row */}
<div className="meta-bar-footer">
<div className="meta-bar-publisher">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix=""
size="sm"
showName
/>
</div>
{tagEntries.length > 0 ? (
<div className="meta-bar-tags">
{tagEntries.map(([tag]) => (
<Badge key={tag} variant="compact">
{tag}
</Badge>
))}
</div>
) : null}
{showDownload ? (
<Button asChild variant="primary" size="sm">
<a href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}>
<Download size={14} aria-hidden="true" />
Download
</a>
</Button>
) : null}
</div>
</div>
);
}
@@ -4,20 +4,11 @@ import { SecurityScanResults } from "./SkillSecurityScanResults";
describe("SecurityScanResults static guidance", () => {
it("renders capability-only states without scanner verdicts", () => {
render(
<SecurityScanResults
capabilityTags={[
"posts-externally",
"requires-oauth-token",
"requires-sensitive-credentials",
]}
/>,
);
render(<SecurityScanResults capabilityTags={["posts-externally", "requires-oauth-token"]} />);
expect(screen.getByText("Capability signals")).toBeTruthy();
expect(screen.getByText("Posts externally")).toBeTruthy();
expect(screen.getByText("Requires OAuth token")).toBeTruthy();
expect(screen.getByText("Requires sensitive credentials")).toBeTruthy();
});
it("renders capability labels separately from scan verdicts", () => {

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