Compare commits

...
Author SHA1 Message Date
Val Alexander 43d59f1a46 fix: align og image to new comp 2026-04-20 21:45:57 -05:00
Val Alexander 1b76c5ac8d fix: reduce og logo scale 2026-04-20 21:26:26 -05:00
Val Alexander 06e2e5aa85 fix: remove og logo panel 2026-04-20 21:13:02 -05:00
Val Alexander b49665ef61 fix: tighten og image layout 2026-04-20 20:53:11 -05:00
Val Alexander 202d1b2962 fix: refine clawhub og image 2026-04-20 17:05:26 -05:00
Val Alexander 37dc4967d8 Refresh OG image branding
- Update Open Graph artwork and logo asset
- Adjust root metadata to use the new social preview image
2026-04-20 16:28:35 -05:00
Val Alexander f0943136c6 fix: emphasize clawhub branding in og image 2026-04-20 15:47:22 -05:00
Val Alexander 65f09e259e fix: center og logo layout 2026-04-20 15:43:35 -05:00
Val Alexander 5edcd0866a fix: refine clawhub og image 2026-04-20 15:40:49 -05:00
Val Alexander c44df1dcbc Refresh OG image design
- Redesign the social preview graphic for the new ClawHub branding
- Bump the OG image version so the updated asset is served
2026-04-20 15:39:36 -05:00
Val Alexander e1d6a2eb89 Refresh OG image and bust cache version
- Replace the social preview artwork with a new branded SVG and updated PNG
- Add a versioned og image URL in the root head tags to ensure the new asset is served
2026-04-20 14:08:41 -05:00
Gustavo Madeira Santana 89246f1927 chore(ui): remove gap before hero cycled words 2026-04-19 13:25:56 -04:00
Val Alexander f4ddccbead enchance: mobile skills ux (#1737) 2026-04-18 20:10:03 -05:00
Val Alexander 3cafcbf873 Mobile search icon + system theme on first load
- Initialize root theme data from stored selection before paint
- Hide the search label on mobile and tighten button padding
2026-04-18 18:40:56 -05:00
Val Alexander 13064a7897 Merge pull request #1731 from openclaw/okcode/fix-mobile-search-button
Fix mobile header branding and add Home link
2026-04-18 17:52:29 -05:00
Val Alexander 194c22f4dd Add branded mobile nav header
- Show the logo mark in the mobile drawer title
- Tighten mobile suggestion spacing on small screens
- Add test coverage for the branded mobile nav header
2026-04-18 17:50:21 -05:00
Val Alexander a693b945fa Add Home link to mobile header navigation
- Insert a Home entry at the top of the mobile menu
- Update header tests to cover the new menu order
2026-04-18 17:40:22 -05:00
Val Alexander 9bef672541 Merge branch 'okcode/polished-card-icons-logo' 2026-04-18 17:28:51 -05:00
Val Alexander 9551cac37b Merge pull request #1729 from openclaw/okcode/fix-settings-update
Stabilize preferences sync and keep diff editor mounted
2026-04-18 17:25:52 -05:00
Val Alexander eb4138fbb3 fix: harden preferences storage sync 2026-04-18 17:24:31 -05:00
Val Alexanderandgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> 5fbead624b Update src/lib/preferences.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-18 17:17:18 -05:00
Val Alexander 35094177e6 Keep diff editor mounted when switching view mode
- Remove the diff editor remount on inline vs side-by-side toggles
- Add a regression test to verify the editor stays mounted
2026-04-18 17:04:30 -05:00
Val Alexander faa5c9f2b5 Polish icons and brand mark styling
- Simplify home and settings labels by removing redundant icons
- Swap automation icons to refresh glyphs in sidebars and toolbar
- Add subtle border and shadow treatment to the brand mark
2026-04-18 17:03:35 -05:00
Val Alexander c3314c2d01 Stabilize preference snapshots and storage sync
- Cache localStorage reads to avoid redundant snapshot churn
- Sync updates across tabs and add coverage for preference re-renders
2026-04-18 17:03:15 -05:00
Val Alexander 7dfa19157c Merge pull request #1573 from openclaw/staging
chore: merge staging into main
2026-04-18 16:46:00 -05:00
Val AlexanderandClaude Opus 4.6 44acf86ac1 merge: resolve AGENTS.md conflict — keep both convex-ai and stat migration rules
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 16:45:37 -05:00
Val AlexanderandClaude Opus 4.6 a0ebc1b50a style: spread footer columns evenly across full width
Switch footer grid from auto-sized centered columns to equal 1fr
columns that span the full screen width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 16:44:28 -05:00
Val AlexanderandClaude Opus 4.6 88dbb69a23 style: adopt darker home-v2 palette globally and unify radius to 8px
Shift all theme variants (claw dark/light, hub dark/light) to the
bolder home-v2 backgrounds (#060608 dark, #faf6f1 light cream).
Harmonize surface, nav-bg, input-bg, and overlay-bg to match.
Set every radius token (--r-lg/md/sm/xs/pill) and home-v2 hardcoded
radii to a single consistent 8px value.
Remove home-v2–specific overrides for app-shell background, navbar
background, footer transparency, and navbar-inner max-width that
previously caused visual divergence between the home page and the
rest of the app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 16:30:53 -05:00
Val Alexander df9acd27e4 update: styles 2026-04-18 16:28:00 -05:00
Val AlexanderandClaude Opus 4.6 dbd5d4042c fix: restore header logo and compact/center footer
Uncomment the brand logo image in the header navbar and reduce footer
vertical padding, gaps, and margins to ~55% of original height while
centering the grid columns and link text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 16:17:17 -05:00
Val AlexanderClaude Opus 4.6copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
ebe82b7e18 Improve about page rejection categories (#1728)
* improve about page rejection categories: add icons, fix grid, polish cards

- Add lucide-react icons to each rejection category card for visual scanning
- Fix unbalanced grid layout by removing featured card sizing, using clean 2/3-col grid
- Fix broken hover transitions (var(--transition-fast) was undefined outside reduced-motion)
- Add lift-on-hover effect and icon glow matching home page card patterns
- Render backtick-wrapped text as styled inline code elements
- Improve description text contrast from ~3.5:1 to ~4.8:1 (WCAG AA)

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

* fix: use h3 for card titles to fix heading hierarchy (WCAG 1.3.1)

Change rejection category card titles from <h2> to <h3> since the parent
section already uses <h2> for "Immediate rejection categories". Updates
the matching CSS selector from .about-rule-card h2 to h3.

Also adds tests for renderWithInlineCode helper covering plain text,
single/multiple code spans, empty input, and code-only strings.

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

* fix: remove white backgrounds from all logo assets

- Remove white backgrounds from clawd-logo.png, clawd-mark.png,
  logo192.png, logo512.png — now transparent PNGs
- Convert white strokes to dark (#1a0808) in both PNGs and logo.svg
  so segments separate cleanly on any background
- Defringe antialiased edges to eliminate white halos
- Regenerate favicon.ico from transparent source
- Update manifest.json background_color from #ffffff to #0a0a0a

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

* consolidate logo assets: delete SVGs, use only PNGs with transparent bg

- Delete public/logo.svg, public/og.svg, src/logo.svg (dead/unused SVGs)
- Remove logo.svg favicon link from __root.tsx (favicon.ico remains)
- Remove white backgrounds from clawd-logo.png and clawd-mark.png
- Convert white strokes to dark (#1a0808), defringe antialiased edges
- Regenerate logo192.png, logo512.png, favicon.ico from clean sources
- Only canonical logo files are now clawd-logo.png and clawd-mark.png

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

* style: widen navbar search bar and polish hero section

Let the search bar span the full width between brand and theme toggle
by removing the oversized right-column minimum and theme-toggle min-width.
Widen the hero search container, subtitle, and tighten vertical padding
for a sleeker feel.

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

* fix: use :is(h2, h3) selector for about-rule-card headings

The /souls page reuses about-rule-card with <h2> elements. Using
:is(h2, h3) ensures both heading levels get styled consistently.

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

* feat: slot machine Easter egg on hero label triple-click

Triple-clicking "BUILT BY THE COMMUNITY" triggers a casino-style slot
machine across all 3 headline words. Reels spin and stop sequentially
with a 1/13 jackpot chance. Winning fires a confetti celebration with
golden text glow. Auto-resets after the animation completes.

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

* feat: add cooldown, longer celebration, and Hack x3 lobster jackpot

- 18s cooldown after a win, 3s after a loss to prevent spam
- Win celebration extended to 10s for screenshot opportunities
- Hack x3 jackpot triggers aquatic theme: cyan/teal text glow,
  ocean-colored confetti with bubble and claw particles, and the
  lobster logo fades in behind the headline

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

* feat: tune slot machine odds to 1/25 any jackpot, 1/100 Hack jackpot

Replace pure random picks with controlled probability: 4% chance of
any jackpot per spin, with 25% of jackpots being Hack (= 1% overall).
Non-jackpot spins re-roll accidental triple matches to keep odds exact.

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

* fix: clean up slot timers on unmount, fix about-grid specificity

Add useEffect cleanup to clear slot machine timers/intervals when
the home route unmounts mid-animation. Fix about-grid media query
specificity by including .about-panel-categories .about-grid to
override the higher-specificity base rule.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-04-18 16:07:48 -05:00
Val AlexanderandClaude Opus 4.6 4c566268a9 fix: hide logo, clean up rejection categories layout (#1727)
* fix: hide logo, use ClawHub as home link, and clean up rejection categories grid

Comment out the brand logo image for now, rename "Immediate rejection
categories" to "Rejection Categories", remove the featured card variant,
and switch to an auto-fill grid so cards spread evenly at full width.
Add overflow: visible on the categories panel to prevent hover shadow
clipping.

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

* fix: impose max page width on home page using --page-max (1536px)

Constrain .home-v2-main to max-width: var(--page-max) and center it
with margin-inline: auto. Extend the home page background color to the
full viewport via .app-shell:has(.home-v2-main) for both light and dark
themes so the background bleeds edge-to-edge beyond the content column.

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

* fix: remove extra footer padding and ensure full-width nav/footer for boxed layout

Zero out the outer .site-footer padding and set background to transparent
on home-v2 pages so the app-shell background bleeds through edge-to-edge.
Remove the redundant light-mode footer background override (app-shell
background already covers it). Nav and footer now visually span full
viewport width while .home-v2-main content stays boxed at --page-max.

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

* fix: reduce carousel card hover effect and increase track padding

The carousel cards were getting clipped by the parent overflow:hidden
container. Reduce the hover transform from translateY(-4px) scale(1.01)
to translateY(-2px) and shrink box-shadow spread across all theme
variants. Increase carousel track top padding from 4px to 12px to
accommodate the upward shift without cutoff.

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

* fix: address review feedback — mobile brand, category grid, hover drama

- Keep brand name visible on mobile (remove display:none for
  .brand-name-responsive at ≤639px) so the home link is always
  discoverable. Add TODO comment on the commented-out logo block.
- Add .about-panel-categories .about-grid to the ≤640px media query
  so the category grid correctly collapses to single-column on mobile.
- Bump carousel card hover to translateY(-3px) with 0 6px 24px shadow
  for a slightly more dramatic lift — still within the 12px top / 48px
  bottom track padding so nothing clips.

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

* chore: remove unused footer divider element

The site-footer-divider was already hidden via CSS (display: none) on
home-v2 pages. Remove the element entirely since it serves no purpose.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 14:37:43 -05:00
Val Alexander e54fc1939a fix: normalize nav and footer layout 2026-04-18 13:41:36 -05:00
Momoandmomothemage 530e39eedc refactor: extract readCanonicalStat and add structural guards for stat field migration (#1709)
Merged via squash.

Prepared head SHA: e92817f66f
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Reviewed-by: @momothemage
2026-04-17 17:44:32 +08:00
copilot-swe-agent[bot]andBunsDev 8b87c31a99 Merge remote-tracking branch 'origin/main' into staging
# Conflicts:
#	src/routes/management.tsx
#	src/routes/settings.tsx

Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
2026-04-17 08:49:21 +00:00
Momo f7bc8b6349 fix(stats): fix skill stat field sync direction and reconcile logic (#1704)
Merged via squash.

Prepared head SHA: e814278382
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Reviewed-by: @momothemage
2026-04-16 20:04:13 +08:00
5b8f09167a fix(api): align inspect security snapshot with static scan moderation
Include static scan results in the skill version security snapshot so inspect/API responses reflect the same moderation-relevant signal already used elsewhere. Also add regression coverage for suspicious, malicious, and static-only scan combinations.

Co-authored-by: davida-ps <232346510+davida-ps@users.noreply.github.com>
Co-authored-by: Luke <92253590+ImLukeF@users.noreply.github.com>
2026-04-16 18:39:39 +10:00
hugh 17fbd13bc9 fix(cli): use explorer on Windows to preserve auth URL params
On Windows, opening auth URLs via `cmd /c start` can truncate query parameters because `&` is treated as a command separator. Use `explorer` instead so the browser opener gets the full URL without shell parsing, and cover the Windows spawn args in the CLI UI test.

Co-authored-by: hugh <1012760428@qq.com>
2026-04-16 14:00:03 +10:00
aab7dc9ba4 fix(upload): fall back to octet-stream for empty Content-Type
Handle browser uploads that provide an empty MIME type by falling back to `application/octet-stream` before sending the storage request.

Co-authored-by: Arthur Katcher <192321283+arthurkatcher@users.noreply.github.com>
Co-authored-by: Luke <92253590+ImLukeF@users.noreply.github.com>
2026-04-16 12:54:51 +10:00
ImLukeF dde8796790 feat: tag skills needing sensitive credentials 2026-04-14 20:09:24 +10:00
Val Alexander acc6d292de Home v2 styles: layout, theme & navbar tweaks
Add and refine styles for the Home V2 UI: introduce navbar search/home styles, motto and headline variants, section copy/eyebrow rules, discovery and categories layouts, and responsive grid stacking. Adjust hv2 color variables (text-secondary/tertiary) and move category border to the grid element; update spacing/alignment for carousel and section headers. Add light/dark theme overrides to improve navbar, tabs and search contrast and hover states. Misc minor typographic and spacing refinements for a more cohesive Home V2 appearance.
2026-04-14 00:27:04 -05:00
ImLukeF 2236ed7be1 feat: add org profile editing 2026-04-14 14:10:52 +10:00
ImLukeF f6fb7ccfc0 Revert "Reapply "feat: allow moderators to transfer skill publishers (#1663)""
This reverts commit b73758c7c8.
2026-04-14 13:39:49 +10:00
ImLukeF b73758c7c8 Reapply "feat: allow moderators to transfer skill publishers (#1663)"
This reverts commit fbc07c5617.
2026-04-14 13:38:30 +10:00
ImLukeF fbc07c5617 Revert "feat: allow moderators to transfer skill publishers (#1663)"
This reverts commit 80e5aec577.
2026-04-14 13:37:08 +10:00
Luke 80e5aec577 feat: allow moderators to transfer skill publishers (#1663) 2026-04-14 13:36:28 +10:00
Val AlexanderandNova f869b31ad6 fix: remove leftover theme-family UI remnants
- drop mobile theme-family section in header
- remove unused theme-family settings bindings

Co-authored-by: Nova <nova@openknot.ai>
2026-04-13 22:15:06 -05:00
Val AlexanderandNova 9a853f2fcc chore: update lockfile and favicon
- refresh bun.lock after dependency reinstall
- include favicon update

Co-authored-by: Nova <nova@openknot.ai>
2026-04-13 22:13:03 -05:00
Val AlexanderandNova b4a7540157 feat: homepage redesign + unified theme + UI polish
- Redesign homepage with hero, search, featured carousel, categories, proof bar, trending
- Add cream/peach/tan light mode palette with inset-shadow pattern (dark + light)
- Remove Hub theme — single Claw theme only (light/dark mode toggle remains)
- Semi-rounded radius system (Claw × Hub midpoint: 4/7/10px)
- Consistent button radius site-wide (--r-btn: 4px), zero makeshift buttons
- Add VITE_FEATURE_SOULS env flag (default: false) to gate Souls pages
- Hide Souls from nav, footer, and homepage categories
- Remove theme family toggle from Header + Settings
- Widen page max to screen-2xl (1536px)
- Slow featured carousel 15% (40s → 46s)

Co-authored-by: Nova <nova@openknot.ai>
2026-04-13 21:59:23 -05:00
Val Alexander 0ea1127a2b fix: refine header and about responsiveness (#1661) 2026-04-13 13:03:40 -05:00
Val Alexander aeab23a6d6 Fix dark-mode styling for skills filter chips (#1660)
- Add readable dark-surface and active-state colors to filter chips
- Cover the toolbar styling with a jsdom test
2026-04-13 13:01:37 -05:00
67 changed files with 5462 additions and 1844 deletions
+20
View File
@@ -95,3 +95,23 @@ When working on Convex code, **always read `convex/_generated/ai/guidelines.md`
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
<!-- convex-ai-end -->
## Stat Field Migration Rules
The `skills` table maintains two parallel sets of stat fields as part of an in-progress field migration:
| Legacy (nested, `@deprecated`) | Top-level (source of truth, indexable) |
|---|---|
| `stats.downloads` | `statsDownloads` |
| `stats.stars` | `statsStars` |
| `stats.installsCurrent` | `statsInstallsCurrent` |
| `stats.installsAllTime` | `statsInstallsAllTime` |
**Rules:**
- **Always use `readCanonicalStat(skill, field)` (`convex/lib/skillStats.ts`) to read** any of the four migrated fields. It prefers the top-level field and falls back to the nested field for pre-migration documents. Never access `skill.stats.downloads` / `.stars` / `.installsCurrent` / `.installsAllTime` directly.
- **Always use `applySkillStatDeltas()` to write** stat deltas. It writes both the top-level and nested fields in the same patch to keep them in sync.
- **Both sets of fields must be written together** in any patch that touches stat values (see the return shape of `applySkillStatDeltas`).
- **Nested-only reads are acceptable only for** `stats.comments` and `stats.versions` — no top-level field exists for these yet.
- The four legacy nested fields are marked `@deprecated` in `statsValidator` (schema.ts). Any IDE access to `skill.stats.downloads` etc. will show a strikethrough warning — treat this as a signal to use `readCanonicalStat()` instead.
- When adding new stat fields, follow the same dual-write pattern and add a cursor-based backfill mutation (see `backfillSkillStatFieldsInternal` for an example).
+5
View File
@@ -5,6 +5,11 @@
### Changed
- Search: add CJK tokenization support (Chinese/Japanese/Korean) with Intl.Segmenter plus fallback behavior to improve skill query matching (#1596) (thanks @pq-dong).
- Stats: centralize migrated skill stat fallback reads through `readCanonicalStat()` and add schema/agent guardrails to discourage direct legacy nested-field access (#1709) (thanks @momothemage).
### 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).
## 0.10.0 - 2026-04-05
+1 -1
View File
@@ -10,7 +10,7 @@
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
</p>
ClawHub is the **public skill registry for Clawdbot**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
ClawHub is the **public skill registry for OpenClaw**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
It's designed for fast browsing + a CLI-friendly API, with moderation hooks and vector search.
It also now exposes a native **OpenClaw package catalog** for code plugins and bundle plugins.
+89 -168
View File
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "clawhub",
@@ -124,9 +123,9 @@
"@ark/util": ["@ark/util@0.56.0", "", {}, "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA=="],
"@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/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/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/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/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
@@ -176,33 +175,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.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="],
"@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/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="],
"@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=="],
"@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.2", "", {}, "sha512-maA3zw9HkdOZORpKyvmxcRFTTOCpClLW01oAuVtzW7LvafHippRz67VHngIBEsIPKEO5j4COItwXKFnzo9/dfA=="],
"@create-markdown/core": ["@create-markdown/core@2.0.3", "", {}, "sha512-qAYukvE603z42OGZF1LzwxxkOVDksB76wXu+fnlKBzGizhR7uN3xHQO8PFFZDqjkZpaTrmtDd768qzl+Ir+3pQ=="],
"@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=="],
"@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=="],
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
"@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-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-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-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-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.1", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w=="],
"@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-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
"@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/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/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="],
@@ -336,7 +335,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.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@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=="],
@@ -424,43 +423,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.58.0", "", { "os": "android", "cpu": "arm" }, "sha512-1T7UN3SsWWxpWyWGn1cT3ASNJOo+pI3eUkmEl7HgtowapcV8kslYpFQcYn431VuxghXakPNlbjRwhqmR37PFOg=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-YdeJKaZckDQL1qa62a1aKq/goyq48aX3yOxaaWqWb4sau4Ee4IiLbamftNLU3zbePky6QsDj6thnSSzHRBjDfA=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.58.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GryzujxuiRv2YFF7bRy8mKcxlbuAN+euVUtGJt9KKbLT8JBUIosamVhcthLh+VEr6KE6cjeVMAQxKAzJcoN7dg=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-7ANS7PpXCfq84xZQ8E5WPs14gwcuPcl+/8TFNXfpSu0CQBXz3cUo2fDpHT8v8HJN+Ut02eacvMAzTnc9s6X4tw=="],
"@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-arm64": ["@oxlint/binding-darwin-arm64@1.60.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pJsgd9AfplLGBm1fIr25V6V14vMrayhx4uIQvlfH7jWs2SZwSrvi3TfgfJySB8T+hvyEH8K2zXljQiUnkgUnfQ=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.58.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-EqdtJSiHweS2vfILNrpyJ6HUwpEq2g7+4Zx1FPi4hu3Hu7tC3znF6ufbXO8Ub2LD4mGgznjI7kSdku9NDD1Mkg=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.60.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ue1aXHX49ivwflKqGJc7zcd/LeLgbhaTcDCQStgx5x06AXgjEAZmvrlMuIkWd4AL4FHQe6QJ9f33z04Cg448VQ=="],
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.58.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-VQt5TH4M42mY20F545G637RKxV/yjwVtKk2vfXuazfReSIiuvWBnv+FVSvIV5fKVTJNjt3GSJibh6JecbhGdBw=="],
"@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-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-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-c7dxM2Zksa45Qw16i2iGY3Fti2NirJ38FrsBsKw+qcJ0OtqTsBgKJLF0xV+yLG56UH01Z8WRPgsw31e0MoRoGQ=="],
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.58.0", "", { "os": "linux", "cpu": "arm" }, "sha512-0BeuFfwlUHlJ1xpEdSD1YO3vByEFGPg36uLjK1JgFaxFb4W6w17F8ET8sz5cheZ4+x5f2xzdnRrrWv83E3Yd8g=="],
"@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-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.58.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TXlZgnPTlxrQzxG9ZXU7BNwx1Ilrr17P3GwZY0If2EzrinqRH3zXPc3HrRcBJgcsoZNMuNL5YivtkJYgp467UQ=="],
"@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-musl": ["@oxlint/binding-linux-arm64-musl@1.58.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zSoYRo5dxHLcUx93Stl2hW3hSNjPt99O70eRVWt5A1zwJ+FPjeCCANCD2a9R4JbHsdcl11TIQOjyigcRVOH2mw=="],
"@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-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.58.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NQ0U/lqxH2/VxBYeAIvMNUK1y0a1bJ3ZicqkF2c6wfakbEciP9jvIE4yNzCFpZaqeIeRYaV7AVGqEO1yrfVPjA=="],
"@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-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.58.0", "", { "os": "linux", "cpu": "none" }, "sha512-X9J+kr3gIC9FT8GuZt0ekzpNUtkBVzMVU4KiKDSlocyQuEgi3gBbXYN8UkQiV77FTusLDPsovjo95YedHr+3yg=="],
"@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-musl": ["@oxlint/binding-linux-riscv64-musl@1.58.0", "", { "os": "linux", "cpu": "none" }, "sha512-CDze3pi1OO3Wvb/QsXjmLEY4XPKGM6kIo82ssNOgmcl1IdndF9VSGAE38YLhADWmOac7fjqhBw82LozuUVxD0Q=="],
"@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-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.58.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-b/89glbxFaEAcA6Uf1FvCNecBJEgcUTsV1quzrqXM/o4R1M4u+2KCVuyGCayN2UpsRWtGGLb+Ver0tBBpxaPog=="],
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.60.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-JkO3C6Gki7Y6h/MiIkFKvHFOz98/YWvQ4WYbK9DLXACMP2rjULzkeGyAzorJE5S1dzLQGFgeqvN779kSFwoV1g=="],
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.58.0", "", { "os": "linux", "cpu": "x64" }, "sha512-0/yYpkq9VJFCEcuRlrViGj8pJUFFvNS4EkEREaN7CB1EcLXJIaVSSa5eCihwBGXtOZxhnblWgxks9juRdNQI7w=="],
"@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-musl": ["@oxlint/binding-linux-x64-musl@1.58.0", "", { "os": "linux", "cpu": "x64" }, "sha512-hr6FNvmcAXiH+JxSvaJ4SJ1HofkdqEElXICW9sm3/Rd5eC3t7kzvmLyRAB3NngKO2wzXRCAm4Z/mGWfrsS4X8w=="],
"@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-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.58.0", "", { "os": "none", "cpu": "arm64" }, "sha512-R+O368VXgRql1K6Xar+FEo7NEwfo13EibPMoTv3sesYQedRXd6m30Dh/7lZMxnrQVFfeo4EOfYIP4FpcgWQNHg=="],
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.60.0", "", { "os": "none", "cpu": "arm64" }, "sha512-H+PUITKHk04stFpWj3x3Kg08Afp/bcXSBi0EhasR5a0Vw7StXHTzdl655PUI0fB4qdh2Wsu6Dsi+3ACxPoyQnA=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.58.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q0FZiAY/3c4YRj4z3h9K1PgaByrifrfbBoODSeX7gy97UtB7pySPUQfC2B/GbxWU6k7CzQrRy5gME10PltLAFQ=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.60.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-WA/yc7f7ZfCefBXVzNHn1Ztulb1EFwNBb4jMZ6pjML0zz6pHujlF3Q3jySluz3XHl/GNeMTntG1seUBWVMlMag=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.58.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-Y8FKBABrSPp9H0QkRLHDHOSUgM/309a3IvOVgPcVxYcX70wxJrk608CuTg7w+C6vEd724X5wJoNkBcGYfH7nNQ=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.60.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-33YxL1sqwYNZXtn3MD/4dno6s0xeedXOJlT1WohkVD565WvohClZUr7vwKdAk954n4xiEWJkewiCr+zLeq7AeA=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.58.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bCn5rbiz5My+Bj7M09sDcnqW0QJyINRVxdZ65x1/Y2tGrMwherwK/lpk+HRQCKvXa8pcaQdF5KY5j54VGZLwNg=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-JOro4ZcfBLamJCyfURQmOQByoorgOdx3ZjAkSqnb/CyG/i+lN3KoV5LAgk5ZAW6DPq7/Cx7n23f8DuTWXTWgyQ=="],
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
@@ -670,7 +669,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.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-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-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
@@ -694,7 +693,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.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="],
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
@@ -724,7 +723,7 @@
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
@@ -742,21 +741,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.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/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/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/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/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/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/pretty-format": ["@vitest/pretty-format@4.1.2", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.4", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A=="],
"@vitest/runner": ["@vitest/runner@4.1.2", "", { "dependencies": { "@vitest/utils": "4.1.2", "pathe": "^2.0.3" } }, "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ=="],
"@vitest/runner": ["@vitest/runner@4.1.4", "", { "dependencies": { "@vitest/utils": "4.1.4", "pathe": "^2.0.3" } }, "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ=="],
"@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/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/spy": ["@vitest/spy@4.1.2", "", {}, "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA=="],
"@vitest/spy": ["@vitest/spy@4.1.4", "", {}, "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ=="],
"@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=="],
"@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=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -788,7 +787,7 @@
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.10", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
@@ -798,9 +797,9 @@
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"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=="],
"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=="],
"caniuse-lite": ["caniuse-lite@1.0.30001780", "", {}, "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
@@ -844,15 +843,15 @@
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"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": ["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-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.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
"cookie-es": ["cookie-es@2.0.1", "", {}, "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA=="],
"crossws": ["crossws@0.4.4", "", { "peerDependencies": { "srvx": ">=0.7.1" }, "optionalPeers": ["srvx"] }, "sha512-w6c4OdpRNnudVmcgr7brb/+/HmYjMQvYToO/oTrprTwxRUiom3LYWU1PMWuD006okbUWpII1Ea9/+kwpUfmyRg=="],
"crossws": ["crossws@0.4.5", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-wUR89x/Rw7/8t+vn0CmGDYM9TD6VtARGb0LD5jq2wjtMy1vCP4M+sm6N6TigWeTYvnA8MoW29NqqXD0ep0rfBA=="],
"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=="],
@@ -882,7 +881,7 @@
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
@@ -896,7 +895,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.321", "", {}, "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.336", "", {}, "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ=="],
"encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
@@ -904,7 +903,7 @@
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"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=="],
"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=="],
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
@@ -926,6 +925,12 @@
"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=="],
@@ -974,7 +979,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.3.1", "", {}, "sha512-XjG/CEoofEisMrnFr0D6U6xOZ4mRfnwcYQ9qvvnT4lvnX8BoeA3x3WofB75D+vZwpaobFVkBIHrZzoK40w8XSw=="],
"httpxy": ["httpxy@0.5.0", "", {}, "sha512-qwX7QX/rK2visT10/b7bSeZWQOMlSm3svTD0pZpU+vJjNUP0YHtNv4c3z+MO+MSnGuRFWJFdCZiV+7F7dXIOzg=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
@@ -1008,7 +1013,7 @@
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
"isbot": ["isbot@5.1.36", "", {}, "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ=="],
"isbot": ["isbot@5.1.38", "", {}, "sha512-Cus2702JamTNMEY4zTP+TShgq/3qzjvGcBC4XMOV45BLaxD4iUFENkqu7ZhFeSzwNsCSZLjnGlihDQznnpnEEA=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
@@ -1024,7 +1029,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.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=="],
"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=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@@ -1062,7 +1067,7 @@
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
"lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="],
"lucia": ["lucia@3.2.2", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0" } }, "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA=="],
@@ -1182,11 +1187,11 @@
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"nf3": ["nf3@0.3.13", "", {}, "sha512-drDt0yl4d/yUhlpD0GzzqahSpA5eUNeIfFq0/aoZb0UlPY0ZwP4u1EfREVvZrYdEnJ3OU9Le9TrzbvWgEkkeKw=="],
"nf3": ["nf3@0.3.16", "", {}, "sha512-Gs0xRPpUm2nDkqbi40NJ9g7qDIcjcJzgExiydnq6LAyqhI2jfno8wG3NKTL+IiJsx799UHOb1CnSd4Wg4SG4Pw=="],
"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.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@@ -1214,7 +1219,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.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": ["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-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=="],
@@ -1234,19 +1239,19 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"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.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"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=="],
"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.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"prettier": ["prettier@3.8.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q=="],
"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=="],
@@ -1254,9 +1259,9 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
@@ -1304,9 +1309,9 @@
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="],
"seroval": ["seroval@1.5.2", "", {}, "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q=="],
"seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="],
"seroval-plugins": ["seroval-plugins@1.5.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg=="],
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
@@ -1322,7 +1327,7 @@
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"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=="],
"solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="],
"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=="],
@@ -1332,7 +1337,7 @@
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"srvx": ["srvx@0.11.12", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-AQfrGqntqVPXgP03pvBDN1KyevHC+KmYVqb8vVf4N+aomQqdhaZxjvoVp+AOm4u6x+GgNQY3MVzAUIn+TqwkOA=="],
"srvx": ["srvx@0.11.15", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
@@ -1362,7 +1367,7 @@
"tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
@@ -1370,17 +1375,17 @@
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
"tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"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.27", "", { "dependencies": { "tldts-core": "^7.0.27" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg=="],
"tldts": ["tldts@7.0.28", "", { "dependencies": { "tldts-core": "^7.0.28" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw=="],
"tldts-core": ["tldts-core@7.0.27", "", {}, "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg=="],
"tldts-core": ["tldts-core@7.0.28", "", {}, "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
@@ -1404,9 +1409,9 @@
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
"undici": ["undici@7.24.7", "", {}, "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ=="],
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
@@ -1442,9 +1447,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.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=="],
"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=="],
"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=="],
"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=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
@@ -1556,13 +1561,13 @@
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
"@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/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
"@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/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@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/@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/@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/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@@ -1580,27 +1585,19 @@
"@tanstack/start-plugin-core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"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=="],
"jsdom/undici": ["undici@7.24.5", "", {}, "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q=="],
<<<<<<< staging
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
=======
"nitro/rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
>>>>>>> main
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
@@ -1610,7 +1607,7 @@
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -1618,7 +1615,6 @@
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
<<<<<<< staging
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -1644,80 +1640,5 @@
"@radix-ui/react-toggle/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
=======
"vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"vitest/vite": ["vite@8.0.1", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw=="],
"nitro/rolldown/@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
"nitro/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
"nitro/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
"nitro/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
"nitro/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
"nitro/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
"nitro/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="],
"nitro/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
"nitro/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
"nitro/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
"nitro/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
"nitro/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
"nitro/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
"nitro/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="],
"nitro/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="],
"nitro/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
"nitro/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
"vitest/vite/rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
"vitest/vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
"vitest/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
"vitest/vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
"vitest/vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
"vitest/vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
"vitest/vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
"vitest/vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="],
"vitest/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
"vitest/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
"vitest/vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
"vitest/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
"vitest/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
"vitest/vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
"vitest/vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="],
"vitest/vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="],
"vitest/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
"vitest/vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
>>>>>>> main
}
}
+148
View File
@@ -1197,6 +1197,154 @@ 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) {
+33 -4
View File
@@ -71,6 +71,11 @@ 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;
@@ -83,6 +88,7 @@ type PublicSkillVersionResponse = {
sha256hash?: string;
vtAnalysis?: Doc<"skillVersions">["vtAnalysis"];
llmAnalysis?: Doc<"skillVersions">["llmAnalysis"];
staticScan?: PublicSkillVersionStaticScan;
capabilityTags?: string[];
};
@@ -194,6 +200,14 @@ 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;
@@ -277,30 +291,35 @@ function hasLlmDimensionWarnings(
function buildSkillSecuritySnapshot(
version: Pick<
PublicSkillVersionResponse,
"sha256hash" | "vtAnalysis" | "llmAnalysis" | "capabilityTags"
"sha256hash" | "vtAnalysis" | "llmAnalysis" | "staticScan" | "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 && capabilityTags.length === 0) return null;
if (!sha256hash && !vt && !llm && !staticScan && 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(vtStatus) || isDefinitiveSecurityStatus(llmStatus);
isDefinitiveSecurityStatus(staticStatus) ||
isDefinitiveSecurityStatus(vtStatus) ||
isDefinitiveSecurityStatus(llmStatus);
const hasWarnings =
status === "suspicious" || status === "malicious" || hasLlmDimensionWarnings(llm?.dimensions);
const checkedAtCandidates = [vt?.checkedAt, llm?.checkedAt].filter(
const checkedAtCandidates = [staticScan?.checkedAt, vt?.checkedAt, llm?.checkedAt].filter(
(value): value is number => typeof value === "number",
);
const checkedAt = checkedAtCandidates.length > 0 ? Math.max(...checkedAtCandidates) : null;
@@ -315,6 +334,16 @@ 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,
+19 -1
View File
@@ -26,6 +26,7 @@ describe("deriveSkillCapabilityTags", () => {
"requires-wallet",
"can-make-purchases",
"can-sign-transactions",
"requires-sensitive-credentials",
]);
});
@@ -39,7 +40,24 @@ describe("deriveSkillCapabilityTags", () => {
fileContents: [],
});
expect(tags).toEqual(["requires-oauth-token", "posts-externally"]);
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"]);
});
it("does not treat generic broadcast wording as a crypto transaction signal", () => {
+19
View File
@@ -4,6 +4,7 @@ export const SKILL_CAPABILITY_TAGS = [
"can-make-purchases",
"can-sign-transactions",
"requires-oauth-token",
"requires-sensitive-credentials",
"posts-externally",
] as const;
@@ -96,6 +97,19 @@ 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/,
@@ -129,6 +143,7 @@ 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");
@@ -136,6 +151,7 @@ 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) {
@@ -144,6 +160,9 @@ 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));
}
+27 -11
View File
@@ -10,18 +10,34 @@ type SkillStatDeltas = {
installsAllTime?: number;
};
/**
* Read the canonical value of a migrated stat field from a skill document.
*
* Top-level fields (`statsDownloads`, etc.) are the source of truth they are
* indexable and kept up-to-date by the event pipeline. The nested `stats.*`
* fields are only used as a fallback for pre-migration documents where the
* top-level field is still `undefined`.
*
* All code that reads a migrated stat value should go through this function
* rather than accessing `skill.stats.*` directly.
*/
export function readCanonicalStat(
skill: Doc<"skills">,
field: "downloads" | "stars" | "installsCurrent" | "installsAllTime",
): number {
const topLevelKey = `stats${field[0].toUpperCase()}${field.slice(1)}` as
| "statsDownloads"
| "statsStars"
| "statsInstallsCurrent"
| "statsInstallsAllTime";
return typeof skill[topLevelKey] === "number" ? skill[topLevelKey]! : (skill.stats[field] ?? 0);
}
export function applySkillStatDeltas(skill: Doc<"skills">, deltas: SkillStatDeltas) {
const currentDownloads =
typeof skill.statsDownloads === "number" ? skill.statsDownloads : skill.stats.downloads;
const currentStars = typeof skill.statsStars === "number" ? skill.statsStars : skill.stats.stars;
const currentInstallsCurrent =
typeof skill.statsInstallsCurrent === "number"
? skill.statsInstallsCurrent
: (skill.stats.installsCurrent ?? 0);
const currentInstallsAllTime =
typeof skill.statsInstallsAllTime === "number"
? skill.statsInstallsAllTime
: (skill.stats.installsAllTime ?? 0);
const currentDownloads = readCanonicalStat(skill, "downloads");
const currentStars = readCanonicalStat(skill, "stars");
const currentInstallsCurrent = readCanonicalStat(skill, "installsCurrent");
const currentInstallsAllTime = readCanonicalStat(skill, "installsAllTime");
const currentComments = skill.stats.comments;
const nextDownloads = Math.max(0, currentDownloads + (deltas.downloads ?? 0));
+62
View File
@@ -7,6 +7,10 @@ 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",
@@ -36,6 +40,7 @@ const {
backfillLatestVersionSummaryInternal,
backfillSkillFingerprintsInternalHandler,
backfillSkillSummariesInternalHandler,
backfillUserStatsInternalHandler,
cleanupEmptySkillsInternalHandler,
nominateEmptySkillSpammersInternalHandler,
upsertSkillBadgeRecordInternal,
@@ -259,6 +264,63 @@ 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,6 +36,11 @@ type BackfillStats = {
missingStorageBlob: number;
};
type UserStatsBackfillStats = {
usersScanned: number;
usersPatched: number;
};
type BackfillPageItem =
| {
kind: "ok";
@@ -57,6 +62,18 @@ 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()),
@@ -136,6 +153,65 @@ 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;
@@ -151,6 +227,20 @@ 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,
@@ -246,6 +336,73 @@ 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()),
@@ -257,6 +414,16 @@ 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()),
+7 -7
View File
@@ -2146,9 +2146,9 @@ export const insertReleaseInternal = internalMutation({
const nextIsOfficial = nextChannel === "official";
const nextOwnerPublisherId = stringifyOptionalId(args.ownerPublisherId ?? null);
const nextOwnerUserId = stringifyId(args.ownerUserId);
const nextName = args.name;
const nextRuntimeId = args.runtimeId ?? null;
const nextVersion = args.version;
const nextNameLabel = typeof args.name === "string" ? args.name : "<unknown>";
const nextRuntimeIdLabel = typeof args.runtimeId === "string" ? args.runtimeId : "<unknown>";
const nextVersionLabel = typeof args.version === "string" ? args.version : "<unknown>";
if (existing) {
const existingIsLegacyPersonalPackage =
!existing.ownerPublisherId &&
@@ -2171,7 +2171,7 @@ export const insertReleaseInternal = internalMutation({
}
if (existing && existing.family !== args.family) {
throw new ConvexError(
`Package "${nextName}" already exists as a ${existing.family}; family changes are not allowed`,
`Package "${nextNameLabel}" already exists as a ${existing.family}; family changes are not allowed`,
);
}
if (
@@ -2182,7 +2182,7 @@ export const insertReleaseInternal = internalMutation({
existing.runtimeId !== args.runtimeId
) {
throw new ConvexError(
`Package "${nextName}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
`Package "${nextNameLabel}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
);
}
if (args.family === "code-plugin" && args.runtimeId) {
@@ -2191,7 +2191,7 @@ export const insertReleaseInternal = internalMutation({
.withIndex("by_runtime_id", (q) => q.eq("runtimeId", args.runtimeId))
.unique();
if (runtimeCollision && runtimeCollision._id !== existing?._id) {
throw new ConvexError(`Plugin id "${nextRuntimeId}" is already claimed by another package`);
throw new ConvexError(`Plugin id "${nextRuntimeIdLabel}" is already claimed by another package`);
}
}
@@ -2228,7 +2228,7 @@ export const insertReleaseInternal = internalMutation({
q.eq("packageId", existing._id).eq("version", args.version),
)
.unique();
if (releaseExists) throw new ConvexError(`Version ${nextVersion} already exists`);
if (releaseExists) throw new ConvexError(`Version ${nextVersionLabel} already exists`);
}
const priorReleases = existing
? await ctx.db
+139
View File
@@ -5,6 +5,7 @@ import {
listMine,
migrateLegacyPublisherHandleToOrgInternal,
removeMember,
updateProfile,
} from "./publishers";
vi.mock("@convex-dev/auth/server", () => ({
@@ -52,6 +53,15 @@ 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);
@@ -371,6 +381,135 @@ 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,6 +539,70 @@ 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(),
+12
View File
@@ -95,10 +95,22 @@ const badgesValidator = v.optional(
}),
);
/**
* Nested stat fields on the `skills` document.
*
* The four migrated fields below are kept for backward compatibility only.
* Always use the top-level fields (`statsDownloads`, `statsStars`,
* `statsInstallsCurrent`, `statsInstallsAllTime`) as the source of truth,
* and use `readCanonicalStat()` / `applySkillStatDeltas()` to read/write them.
*/
const statsValidator = v.object({
/** @deprecated Use top-level `statsDownloads` instead. */
downloads: v.number(),
/** @deprecated Use top-level `statsInstallsCurrent` instead. */
installsCurrent: v.optional(v.number()),
/** @deprecated Use top-level `statsInstallsAllTime` instead. */
installsAllTime: v.optional(v.number()),
/** @deprecated Use top-level `statsStars` instead. */
stars: v.number(),
versions: v.number(),
comments: v.number(),
-419
View File
@@ -1,419 +0,0 @@
import type { Id } from "./_generated/dataModel";
import { internalMutation } from "./functions";
const DEMO_SKILLS = [
{
slug: "mcp-github",
displayName: "MCP GitHub",
summary: "Full GitHub API integration via MCP — issues, PRs, repos, code search, and actions.",
downloads: 14200,
stars: 342,
installs: 8100,
},
{
slug: "claude-memory",
displayName: "Claude Memory",
summary: "Persistent memory layer for Claude — stores context across conversations with vector recall.",
downloads: 11800,
stars: 287,
installs: 6400,
},
{
slug: "web-scraper-pro",
displayName: "Web Scraper Pro",
summary: "Intelligent web scraping with automatic pagination, JS rendering, and structured data extraction.",
downloads: 9400,
stars: 198,
installs: 5200,
},
{
slug: "sql-analyst",
displayName: "SQL Analyst",
summary: "Natural language to SQL with schema introspection, query optimization, and result visualization.",
downloads: 8700,
stars: 221,
installs: 4800,
},
{
slug: "pytest-agent",
displayName: "Pytest Agent",
summary: "Automated test generation and execution for Python — coverage analysis, mutation testing, fixtures.",
downloads: 7200,
stars: 156,
installs: 3900,
},
{
slug: "docker-compose-helper",
displayName: "Docker Compose Helper",
summary: "Generate, validate, and debug Docker Compose configurations with multi-service orchestration.",
downloads: 6800,
stars: 134,
installs: 3600,
},
{
slug: "api-docs-generator",
displayName: "API Docs Generator",
summary: "Auto-generate OpenAPI specs and beautiful documentation from any codebase or endpoint.",
downloads: 5900,
stars: 178,
installs: 3100,
},
{
slug: "slack-bot-builder",
displayName: "Slack Bot Builder",
summary: "Build and deploy Slack bots with natural language — slash commands, modals, and event handlers.",
downloads: 5400,
stars: 112,
installs: 2800,
},
{
slug: "terraform-assistant",
displayName: "Terraform Assistant",
summary: "Infrastructure as code helper — plan reviews, drift detection, module generation for AWS/GCP/Azure.",
downloads: 4800,
stars: 145,
installs: 2400,
},
{
slug: "regex-wizard",
displayName: "Regex Wizard",
summary: "Natural language to regex with live testing, explanation, and edge case generation.",
downloads: 4200,
stars: 89,
installs: 2100,
},
{
slug: "git-history-explorer",
displayName: "Git History Explorer",
summary: "Semantic search through git history — find commits by intent, trace code evolution, blame analysis.",
downloads: 3900,
stars: 102,
installs: 1900,
},
{
slug: "cron-scheduler",
displayName: "Cron Scheduler",
summary: "Natural language to cron expressions with timezone handling, overlap protection, and monitoring.",
downloads: 3400,
stars: 67,
installs: 1600,
},
{
slug: "jwt-debugger",
displayName: "JWT Debugger",
summary: "Decode, verify, and generate JWTs with visual payload inspection and expiry tracking.",
downloads: 3100,
stars: 78,
installs: 1400,
},
{
slug: "graphql-builder",
displayName: "GraphQL Builder",
summary: "Schema-first GraphQL development — type generation, resolver scaffolding, and playground integration.",
downloads: 2800,
stars: 94,
installs: 1200,
},
{
slug: "security-scanner",
displayName: "Security Scanner",
summary: "OWASP-aware security scanning for codebases — dependency audit, secret detection, SAST patterns.",
downloads: 2500,
stars: 156,
installs: 1100,
},
{
slug: "markdown-slides",
displayName: "Markdown Slides",
summary: "Turn markdown into presentation decks with themes, speaker notes, and PDF export.",
downloads: 2200,
stars: 45,
installs: 900,
},
{
slug: "env-manager",
displayName: "Env Manager",
summary: "Environment variable management across projects — sync .env files, validate schemas, rotate secrets.",
downloads: 1800,
stars: 56,
installs: 800,
},
{
slug: "csv-transform",
displayName: "CSV Transform",
summary: "Powerful CSV/TSV manipulation — column transforms, joins, pivots, and format conversion.",
downloads: 1500,
stars: 34,
installs: 600,
},
{
slug: "ssh-config-manager",
displayName: "SSH Config Manager",
summary: "Manage SSH configs, keys, and tunnels with natural language — jump hosts, port forwarding, agent setup.",
downloads: 1200,
stars: 42,
installs: 500,
},
{
slug: "changelog-writer",
displayName: "Changelog Writer",
summary: "Generate changelogs from git history with conventional commit parsing and release note formatting.",
downloads: 980,
stars: 28,
installs: 400,
},
];
const DEMO_OWNERS = [
{ handle: "anthropic", displayName: "Anthropic", highlighted: true },
{ handle: "openai-labs", displayName: "OpenAI Labs", highlighted: false },
{ handle: "devtools-co", displayName: "DevTools Co", highlighted: false },
{ handle: "securityfirst", displayName: "SecurityFirst", highlighted: true },
{ handle: "dataflow", displayName: "DataFlow", highlighted: false },
];
export const seedDemoSkills = internalMutation({
args: {},
handler: async (ctx) => {
// Check if we already seeded
const existingSkill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", "mcp-github"))
.first();
if (existingSkill) {
return { seeded: false, reason: "already seeded" };
}
// Create a seed user
const seedUserId = await ctx.db.insert("users", {
name: "ClawHub Demo",
displayName: "ClawHub Demo",
handle: "clawhub-demo",
image: undefined,
role: "admin",
});
// Create publisher accounts
const publisherIds: string[] = [];
for (const owner of DEMO_OWNERS) {
const pubId = await ctx.db.insert("publishers", {
kind: "org",
handle: owner.handle,
displayName: owner.displayName,
linkedUserId: seedUserId,
createdAt: Date.now(),
updatedAt: Date.now(),
});
publisherIds.push(pubId);
// Add membership
await ctx.db.insert("publisherMembers", {
publisherId: pubId as Id<"publishers">,
userId: seedUserId,
role: "owner",
createdAt: Date.now(),
updatedAt: Date.now(),
});
}
const now = Date.now();
const DAY = 86400000;
let totalPublishedSkills = 0;
let totalStars = 0;
let totalDownloads = 0;
for (let i = 0; i < DEMO_SKILLS.length; i++) {
const s = DEMO_SKILLS[i];
const ownerIdx = i % publisherIds.length;
const createdDaysAgo = Math.floor(Math.random() * 90) + 7;
const updatedDaysAgo = Math.floor(Math.random() * createdDaysAgo);
const createdAt = now - createdDaysAgo * DAY;
const updatedAt = now - updatedDaysAgo * DAY;
const version = `${Math.floor(Math.random() * 3) + 1}.${Math.floor(Math.random() * 10)}.${Math.floor(Math.random() * 20)}`;
const isHighlighted = i < 6;
const badges = isHighlighted
? { highlighted: { byUserId: seedUserId, at: now } }
: undefined;
const numVersions = Math.floor(Math.random() * 8) + 1;
const numComments = Math.floor(Math.random() * 15);
// Create skill first (without latestVersionId)
const skillId = await ctx.db.insert("skills", {
slug: s.slug,
displayName: s.displayName,
summary: s.summary,
ownerUserId: seedUserId,
ownerPublisherId: publisherIds[ownerIdx] as Id<"publishers">,
tags: {},
badges,
moderationStatus: "active",
moderationVerdict: "clean",
stats: {
downloads: s.downloads,
installsCurrent: Math.floor(s.installs * 0.3),
installsAllTime: s.installs,
stars: s.stars,
versions: numVersions,
comments: numComments,
},
statsDownloads: s.downloads,
statsStars: s.stars,
statsInstallsCurrent: Math.floor(s.installs * 0.3),
statsInstallsAllTime: s.installs,
createdAt,
updatedAt,
});
// Create skillBadges entry for highlighted skills
if (isHighlighted) {
await ctx.db.insert("skillBadges", {
skillId,
kind: "highlighted",
byUserId: seedUserId,
at: now,
});
}
// Now create version with real skillId
const versionId = await ctx.db.insert("skillVersions", {
skillId,
version,
changelog: `Release ${version} — improvements and bug fixes.`,
files: [],
parsed: { frontmatter: {} },
createdBy: seedUserId,
createdAt: updatedAt,
});
// Patch skill with version info
await ctx.db.patch(skillId, {
latestVersionId: versionId,
latestVersionSummary: {
version,
createdAt: updatedAt,
changelog: `Release ${version}`,
},
tags: { latest: versionId },
});
totalPublishedSkills += 1;
totalStars += s.stars;
totalDownloads += s.downloads;
// Create digest for search
await ctx.db.insert("skillSearchDigest", {
skillId,
slug: s.slug,
displayName: s.displayName,
summary: s.summary,
ownerUserId: seedUserId,
ownerPublisherId: publisherIds[ownerIdx] as Id<"publishers">,
ownerHandle: DEMO_OWNERS[ownerIdx].handle,
ownerName: DEMO_OWNERS[ownerIdx].displayName,
ownerDisplayName: DEMO_OWNERS[ownerIdx].displayName,
ownerImage: undefined,
latestVersionId: versionId,
latestVersionSummary: {
version,
createdAt: updatedAt,
changelog: `Release ${version}`,
},
tags: { latest: versionId },
badges,
stats: {
downloads: s.downloads,
installsCurrent: Math.floor(s.installs * 0.3),
installsAllTime: s.installs,
stars: s.stars,
versions: numVersions,
comments: numComments,
},
versions: numVersions,
comments: numComments,
moderationReason: undefined,
isSuspicious: false,
createdAt,
updatedAt,
});
}
await ctx.db.patch(seedUserId, {
publishedSkills: totalPublishedSkills,
totalStars,
totalDownloads,
});
return { seeded: true, count: DEMO_SKILLS.length };
},
});
// Repair globalStats count to match actual seeded data
export const repairGlobalStats = internalMutation({
args: {},
handler: async (ctx) => {
// Count active digests — push filter server-side
const digests = await ctx.db
.query("skillSearchDigest")
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
.filter((q) => q.eq(q.field("moderationStatus"), "active"))
.collect();
const count = digests.length;
// Update globalStats
const stats = await ctx.db
.query("globalStats")
.filter((q) => q.eq(q.field("key"), "default"))
.first();
if (stats) {
await ctx.db.patch(stats._id, { activeSkillsCount: count, updatedAt: Date.now() });
} else {
await ctx.db.insert("globalStats", {
key: "default",
activeSkillsCount: count,
updatedAt: Date.now(),
});
}
return { count };
},
});
// Repair function to add missing skillBadges for already-seeded data
export const repairHighlightedBadges = internalMutation({
args: {},
handler: async (ctx) => {
const highlightedSlugs = DEMO_SKILLS.slice(0, 6).map((s) => s.slug);
let fixed = 0;
for (const slug of highlightedSlugs) {
const skill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.first();
if (!skill) continue;
// Check if badge already exists
const existing = await ctx.db
.query("skillBadges")
.withIndex("by_skill_kind", (q) =>
q.eq("skillId", skill._id).eq("kind", "highlighted"),
)
.first();
if (existing) continue;
await ctx.db.insert("skillBadges", {
skillId: skill._id,
kind: "highlighted",
byUserId: skill.ownerUserId,
at: Date.now(),
});
fixed++;
}
return { fixed };
},
});
+303
View File
@@ -0,0 +1,303 @@
/* @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();
});
});
+107 -58
View File
@@ -183,25 +183,53 @@ 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;
if (
// 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 =
skill.statsDownloads === nextDownloads &&
skill.statsStars === nextStars &&
skill.statsInstallsCurrent === nextInstallsCurrent &&
skill.statsInstallsAllTime === nextInstallsAllTime
) {
skill.statsInstallsAllTime === nextInstallsAllTime;
const nestedInSync =
stats.downloads === nextDownloads &&
stats.stars === nextStars &&
(stats.installsCurrent ?? 0) === nextInstallsCurrent &&
(stats.installsAllTime ?? 0) === nextInstallsAllTime;
if (topLevelInSync && nestedInSync) {
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,
},
};
}
@@ -215,63 +243,79 @@ 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: 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,
};
},
handler: reconcileSkillStarCountsHandler,
});
export const runReconcileSkillStarCountsInternal = internalAction({
@@ -308,6 +352,11 @@ 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.
+135
View File
@@ -0,0 +1,135 @@
import { expect, test } from "@playwright/test";
import { expectHealthyPage, trackRuntimeErrors } from "./helpers/runtimeErrors";
// Only run in mobile projects — skip on desktop
test.beforeEach(({}, testInfo) => {
test.skip(
!testInfo.project.name.includes("mobile"),
"mobile-only test",
);
});
test("browse page has no horizontal overflow on mobile", async ({ page }) => {
const errors = trackRuntimeErrors(page);
await page.goto("/skills?sort=downloads", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: /^Skills/ })).toBeVisible();
await expect(page.locator(".skill-card, .skill-list-item").first()).toBeVisible();
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
await expectHealthyPage(page, errors);
});
test("browse sidebar toggle opens and closes filters", async ({ page }) => {
const errors = trackRuntimeErrors(page);
await page.goto("/skills?sort=downloads", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: /^Skills/ })).toBeVisible();
const filterButton = page.getByRole("button", { name: "Toggle filters" });
await expect(filterButton).toBeVisible();
// Sidebar should be hidden initially
const sidebar = page.locator(".browse-sidebar");
await expect(sidebar).not.toBeVisible();
// Open sidebar
await filterButton.click();
await expect(sidebar).toBeVisible();
// Close sidebar
await filterButton.click();
await expect(sidebar).not.toBeVisible();
await expectHealthyPage(page, errors);
});
test("card grid fits within viewport on mobile", async ({ page }) => {
const errors = trackRuntimeErrors(page);
await page.goto("/skills?sort=downloads&view=cards", { waitUntil: "domcontentloaded" });
await expect(page.locator(".skill-card").first()).toBeVisible();
const card = page.locator(".skill-card").first();
const cardBox = await card.boundingBox();
const viewport = page.viewportSize()!;
// Card should not exceed viewport width
expect(cardBox!.width).toBeLessThanOrEqual(viewport.width);
await expectHealthyPage(page, errors);
});
test("skill detail page has no horizontal overflow on mobile", async ({ page, request }) => {
const errors = trackRuntimeErrors(page);
const response = await request.get("/api/v1/skills/gifgrep");
test.skip(!response.ok(), "gifgrep fixture missing");
const payload = (await response.json()) as {
owner?: { handle?: string | null };
skill?: { slug?: string | null; displayName?: string | null };
};
const ownerHandle = payload.owner?.handle?.trim();
const slug = payload.skill?.slug?.trim();
test.skip(!ownerHandle || !slug || !payload.skill?.displayName, "fixture missing owner handle, slug, or displayName");
await page.goto(`/${ownerHandle}/${slug}`, { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: payload.skill!.displayName! }),
).toBeVisible();
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
await expectHealthyPage(page, errors);
});
test("detail tabs are scrollable and touch-friendly on mobile", async ({ page, request }) => {
const errors = trackRuntimeErrors(page);
const response = await request.get("/api/v1/skills/gifgrep");
test.skip(!response.ok(), "gifgrep fixture missing");
const payload = (await response.json()) as {
owner?: { handle?: string | null };
skill?: { slug?: string | null };
};
const ownerHandle = payload.owner?.handle?.trim();
const slug = payload.skill?.slug?.trim();
test.skip(!ownerHandle || !slug, "fixture missing");
await page.goto(`/${ownerHandle}/${slug}`, { waitUntil: "domcontentloaded" });
// All standard tabs should be accessible (even if scrolled)
for (const tabName of ["README", "Files", "Versions"]) {
const tab = page.getByRole("button", { name: tabName });
await tab.scrollIntoViewIfNeeded();
await expect(tab).toBeVisible();
// Touch target should be at least 44px
const box = await tab.boundingBox();
expect(box!.height).toBeGreaterThanOrEqual(44);
}
await expectHealthyPage(page, errors);
});
test("search input font size prevents iOS zoom", async ({ page }) => {
const errors = trackRuntimeErrors(page);
await page.goto("/skills?sort=downloads", { waitUntil: "domcontentloaded" });
const input = page.locator(".browse-search-input");
await expect(input).toBeVisible();
const fontSize = await input.evaluate((el) => getComputedStyle(el).fontSize);
// iOS Safari zooms the page when an input has font-size below 16px
expect(parseFloat(fontSize)).toBeGreaterThanOrEqual(16);
await expectHealthyPage(page, errors);
});
+21
View File
@@ -3,6 +3,7 @@
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),
@@ -26,6 +27,26 @@ 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"
? ["cmd", "/c", "start", "", url]
? ["explorer", url]
: ["xdg-open", url];
const [command, ...commandArgs] = args;
if (!command) return;
+8
View File
@@ -29,5 +29,13 @@ export default defineConfig({
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "mobile-chrome",
use: { ...devices["Pixel 7"] },
},
{
name: "mobile-safari",
use: { ...devices["iPhone 14"] },
},
],
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 792 B

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

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 285 KiB

+1 -1
View File
@@ -21,5 +21,5 @@
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
"background_color": "#0a0a0a"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 281 KiB

+84 -80
View File
@@ -1,98 +1,102 @@
<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 id="bgGlowRight" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(1048 328) rotate(180) scale(358 260)">
<stop stop-color="#7B1F18" stop-opacity="0.58"/>
<stop offset="0.45" stop-color="#431210" stop-opacity="0.26"/>
<stop offset="1" stop-color="#030305" 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 id="bgGlowBottom" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(192 610) rotate(-90) scale(180 420)">
<stop stop-color="#A12A1D" stop-opacity="0.2"/>
<stop offset="1" stop-color="#030305" 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 id="frameStroke" x1="40" y1="106" x2="1146" y2="530" gradientUnits="userSpaceOnUse">
<stop stop-color="#6D3437" stop-opacity="0.8"/>
<stop offset="0.55" stop-color="#DF5D35" stop-opacity="0.36"/>
<stop offset="1" stop-color="#FF6D39" stop-opacity="0.9"/>
</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 id="frameGlow" x1="164" y1="164" x2="1114" y2="476" gradientUnits="userSpaceOnUse">
<stop stop-color="#130D10"/>
<stop offset="0.5" stop-color="#170B0E"/>
<stop offset="1" stop-color="#261011"/>
</linearGradient>
<linearGradient id="logoStroke" x1="112" y1="140" x2="398" y2="430" gradientUnits="userSpaceOnUse">
<stop stop-color="#4C2A28" stop-opacity="0.55"/>
<stop offset="1" stop-color="#E05831" stop-opacity="0.28"/>
</linearGradient>
<linearGradient id="searchStroke" x1="146" y1="480" x2="1068" y2="480" gradientUnits="userSpaceOnUse">
<stop stop-color="#7F342A" stop-opacity="0.65"/>
<stop offset="1" stop-color="#FF6F37" stop-opacity="0.85"/>
</linearGradient>
<linearGradient id="buttonFill" x1="838" y1="445" x2="1084" y2="510" gradientUnits="userSpaceOnUse">
<stop stop-color="#D55335"/>
<stop offset="1" stop-color="#EB6A3E"/>
</linearGradient>
<filter id="softBlur" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="20"/>
</filter>
<filter id="glowBlur" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="10"/>
</filter>
</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)"/>
<rect width="1200" height="630" fill="#030305"/>
<rect width="1200" height="630" fill="url(#bgGlowRight)"/>
<rect width="1200" height="630" fill="url(#bgGlowBottom)"/>
<!-- 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 opacity="0.22">
<circle cx="998" cy="406" r="1.8" fill="#FF7649"/>
<circle cx="1036" cy="446" r="1.4" fill="#FF7649"/>
<circle cx="1088" cy="492" r="1.2" fill="#FF7649"/>
<circle cx="1116" cy="540" r="1.1" fill="#FF7649"/>
<circle cx="964" cy="502" r="1.3" fill="#FF7649"/>
<circle cx="880" cy="528" r="1.3" fill="#FF7649"/>
<circle cx="716" cy="452" r="1.1" fill="#FF7649"/>
<circle cx="622" cy="396" r="1.4" fill="#FF7649"/>
<circle cx="188" cy="558" r="1.3" fill="#FF7649"/>
<circle cx="152" cy="580" r="1.1" fill="#FF7649"/>
<circle cx="92" cy="594" r="1.4" fill="#FF7649"/>
</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>
<path d="M870 146C990 166 1082 230 1142 328" stroke="#AA3D2B" stroke-opacity="0.16" stroke-width="2"/>
<path d="M926 190C1036 234 1108 306 1168 420" stroke="#AA3D2B" stroke-opacity="0.12" stroke-width="2"/>
<path d="M1044 374H1200" stroke="#B74A36" stroke-opacity="0.28" stroke-width="2"/>
<path d="M24 522H164" stroke="#B74A36" stroke-opacity="0.22" stroke-width="2"/>
<!-- 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>
<rect x="42" y="108" width="1092" height="430" rx="42" fill="url(#frameGlow)"/>
<rect x="42.75" y="108.75" width="1090.5" height="428.5" rx="41.25" stroke="url(#frameStroke)" stroke-width="1.5"/>
<rect x="113" y="148" width="292" height="292" rx="38" fill="#09090C"/>
<rect x="113.75" y="148.75" width="290.5" height="290.5" rx="37.25" stroke="url(#logoStroke)" stroke-width="1.5"/>
<!-- Tiny mark -->
<image href="clawd-mark.png" x="108" y="134" width="46" height="46" preserveAspectRatio="xMidYMid meet"/>
<ellipse cx="1118" cy="180" rx="86" ry="42" fill="#FF6532" fill-opacity="0.18" filter="url(#glowBlur)"/>
<ellipse cx="1018" cy="328" rx="208" ry="164" fill="#8A2218" fill-opacity="0.12" filter="url(#softBlur)"/>
<ellipse cx="96" cy="532" rx="48" ry="10" fill="#FF5E35" fill-opacity="0.24" filter="url(#softBlur)"/>
<ellipse cx="572" cy="494" rx="302" ry="12" fill="#FF5E35" fill-opacity="0.12" filter="url(#softBlur)"/>
<!-- 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>
<image href="clawd-logo.png" x="124" y="158" width="270" height="270" preserveAspectRatio="xMidYMid meet"/>
<!-- 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 x="500" y="256" fill="#F8EEE8" font-size="88" font-weight="900" letter-spacing="-4.2" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">ClawHub.ai</text>
<text x="500" y="338" fill="#F8EEE8" font-size="42" font-weight="800" letter-spacing="-1.2" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">Equip. Install. <tspan fill="#FF6236">Build.</tspan></text>
<text x="500" y="390" fill="#E1D4CF" font-size="23" font-weight="500" letter-spacing="-0.15" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">
<tspan x="500" dy="0">Developer tools and agent skills</tspan>
<tspan x="500" dy="28">for your next project.</tspan>
</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>
<g>
<rect x="148" y="432" width="924" height="104" rx="31" fill="#11090D"/>
<rect x="148.75" y="432.75" width="922.5" height="102.5" rx="30.25" stroke="url(#searchStroke)" stroke-width="1.5"/>
<circle cx="220" cy="484" r="19" stroke="#FFF9F3" stroke-width="5"/>
<line x1="233" y1="497" x2="249" y2="513" stroke="#FFF9F3" stroke-width="5" stroke-linecap="round"/>
<text x="272" y="495" fill="#DCD0CB" font-size="31" font-weight="600" letter-spacing="-0.4" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">What are you looking for?</text>
<rect x="824" y="450" width="258" height="66" rx="21" fill="url(#buttonFill)"/>
<text x="953" y="494" text-anchor="middle" fill="#FFF8F1" font-size="28" font-weight="800" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">Search tools</text>
</g>
<g>
<rect x="292" y="574" width="214" height="44" rx="22" fill="#1B1317" stroke="#3A292C"/>
<text x="399" y="603" text-anchor="middle" fill="#EEE3DF" font-size="18" font-weight="500" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">self-improving</text>
<rect x="530" y="574" width="248" height="44" rx="22" fill="#1B1317" stroke="#3A292C"/>
<text x="654" y="603" text-anchor="middle" fill="#EEE3DF" font-size="18" font-weight="500" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">GitHub integration</text>
<rect x="802" y="574" width="180" height="44" rx="22" fill="#1B1317" stroke="#3A292C"/>
<text x="892" y="603" text-anchor="middle" fill="#EEE3DF" font-size="18" font-weight="500" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">dashboard</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

+38 -11
View File
@@ -1,6 +1,39 @@
import { copyFile, mkdir } from "node:fs/promises";
import { copyFile, mkdir, stat } from "node:fs/promises";
import path from "node:path";
async function resolveExistingPath(candidates: string[]) {
for (const candidate of candidates) {
try {
await stat(candidate);
return candidate;
} catch {
// Try next candidate.
}
}
throw new Error(`Missing required asset. Tried: ${candidates.join(", ")}`);
}
function nodeModuleCandidates(relativePath: string) {
return [
path.resolve(`node_modules/${relativePath}`),
path.resolve(`../../node_modules/${relativePath}`),
];
}
const resvgWasmSource = await resolveExistingPath(
nodeModuleCandidates("@resvg/resvg-wasm/index_bg.wasm"),
);
const bricolage800Source = await resolveExistingPath(
nodeModuleCandidates("@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2"),
);
const bricolage500Source = await resolveExistingPath(
nodeModuleCandidates("@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2"),
);
const ibmPlex500Source = await resolveExistingPath(
nodeModuleCandidates("@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2"),
);
const copies = [
{
source: path.resolve("public/clawd-mark.png"),
@@ -12,7 +45,7 @@ const copies = [
],
},
{
source: path.resolve("node_modules/@resvg/resvg-wasm/index_bg.wasm"),
source: resvgWasmSource,
targets: [
path.resolve(".output/server/node_modules/@resvg/resvg-wasm/index_bg.wasm"),
path.resolve(
@@ -21,9 +54,7 @@ const copies = [
],
},
{
source: path.resolve(
"node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2",
),
source: bricolage800Source,
targets: [
path.resolve(
".output/server/node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2",
@@ -34,9 +65,7 @@ const copies = [
],
},
{
source: path.resolve(
"node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2",
),
source: bricolage500Source,
targets: [
path.resolve(
".output/server/node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2",
@@ -47,9 +76,7 @@ const copies = [
],
},
{
source: path.resolve(
"node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2",
),
source: ibmPlex500Source,
targets: [
path.resolve(
".output/server/node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2",
+51
View File
@@ -0,0 +1,51 @@
/* @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");
});
});
+135 -78
View File
@@ -6,131 +6,188 @@ 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: () => vi.fn(),
Link: (props: {
children: ReactNode;
className?: string;
hash?: string;
to?: string;
}) => (
<a
href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`}
className={props.className}
>
{props.children}
</a>
),
useLocation: () => ({ pathname: "/" }),
useNavigate: () => navigateMock,
}));
vi.mock("@convex-dev/auth/react", () => ({
useAuthActions: () => ({
signIn: vi.fn(),
signOut: vi.fn(),
}),
useAuthActions: () => ({
signIn: vi.fn(),
signOut: vi.fn(),
}),
}));
const authStatusMock = vi.fn(() => ({
isAuthenticated: false,
isLoading: false,
me: null,
isAuthenticated: false,
isLoading: false,
me: null,
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => authStatusMock(),
useAuthStatus: () => authStatusMock(),
}));
const setThemeMock = vi.fn();
const setModeMock = vi.fn();
vi.mock("../lib/theme", () => ({
applyTheme: vi.fn(),
THEME_OPTIONS: [
{ value: "claw", label: "Claw", description: "" },
{ value: "hub", label: "Hub", description: "" },
],
useThemeMode: () => ({
theme: "hub",
mode: "system",
setTheme: setThemeMock,
setMode: setModeMock,
}),
applyTheme: vi.fn(),
THEME_OPTIONS: [
{ value: "claw", label: "Claw", description: "" },
{ value: "hub", label: "Hub", description: "" },
],
useThemeMode: () => ({
theme: "hub",
mode: "system",
setTheme: setThemeMock,
setMode: setModeMock,
}),
}));
vi.mock("../lib/theme-transition", () => ({
startThemeTransition: ({
setTheme,
nextTheme,
}: {
setTheme: (value: string) => void;
nextTheme: string;
}) => setTheme(nextTheme),
startThemeTransition: ({
setTheme,
nextTheme,
}: {
setTheme: (value: string) => void;
nextTheme: string;
}) => setTheme(nextTheme),
}));
vi.mock("../lib/useAuthError", () => ({
setAuthError: vi.fn(),
useAuthError: () => ({
error: null,
clear: vi.fn(),
}),
setAuthError: vi.fn(),
useAuthError: () => ({
error: null,
clear: vi.fn(),
}),
}));
vi.mock("../lib/roles", () => ({
isModerator: () => false,
isModerator: () => false,
}));
vi.mock("../lib/site", () => ({
getClawHubSiteUrl: () => "https://clawhub.ai",
getSiteMode: () => siteModeMock(),
getSiteName: () => "OnlyCrabs",
getClawHubSiteUrl: () => "https://clawhub.ai",
getSiteMode: () => siteModeMock(),
getSiteName: () => "OnlyCrabs",
}));
vi.mock("../lib/gravatar", () => ({
gravatarUrl: vi.fn(),
gravatarUrl: vi.fn(),
}));
vi.mock("../components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuSeparator: () => <hr />,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenu: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuItem: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuSeparator: () => <hr />,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock("../components/ui/toggle-group", () => ({
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ToggleGroupItem: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
<button type="button">{children}</button>
),
}));
describe("Header", () => {
it("hides Packages navigation in soul mode on mobile and desktop", () => {
siteModeMock.mockReturnValue("souls");
it("hides Packages navigation in soul mode on mobile and desktop", () => {
siteModeMock.mockReturnValue("souls");
render(<Header />);
render(<Header />);
expect(screen.queryByText("Packages")).toBeNull();
});
expect(screen.queryByText("Packages")).toBeNull();
});
it("renders direct desktop theme family controls and plain Skills tab", () => {
siteModeMock.mockReturnValue("skills");
setThemeMock.mockClear();
it("renders direct desktop theme family controls and plain Skills tab", () => {
siteModeMock.mockReturnValue("skills");
setThemeMock.mockClear();
setModeMock.mockClear();
render(<Header />);
render(<Header />);
expect(screen.getByText("Theme")).toBeTruthy();
expect(screen.getByRole("button", { name: "Claw" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Hub" })).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);
expect(screen.getAllByText("Souls")).toHaveLength(1);
expect(screen.getAllByText("Users")).toHaveLength(1);
expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy();
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: "Hub" }));
expect(setThemeMock).toHaveBeenCalledWith("hub");
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
expect(setModeMock).toHaveBeenCalledWith("light");
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Souls")).toHaveLength(2);
expect(screen.getAllByText("Users")).toHaveLength(2);
});
expect(screen.getAllByText("Home")).toHaveLength(1);
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Users")).toHaveLength(2);
});
it("shows Home above Skills in the mobile menu", () => {
siteModeMock.mockReturnValue("skills");
render(<Header />);
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
expect(document.querySelector(".mobile-nav-brand-mark-image")).toBeTruthy();
const labels = Array.from(
document.querySelectorAll(".mobile-nav-section .mobile-nav-link"),
)
.map((element) => element.textContent?.trim())
.filter((label): label is string => Boolean(label));
expect(labels.slice(0, 2)).toEqual(["Home", "Skills"]);
});
it("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,
},
});
});
});
+38 -1
View File
@@ -28,6 +28,7 @@ let loaderDataMock: {
nextCursor: string | null;
rateLimited: boolean;
retryAfterSeconds: number | null;
apiError?: boolean;
} = {
items: [],
nextCursor: null,
@@ -72,7 +73,13 @@ describe("plugins route", () => {
isRateLimitedPackageApiErrorMock.mockClear();
navigateMock.mockReset();
searchMock = {};
loaderDataMock = { items: [], nextCursor: null, rateLimited: false, retryAfterSeconds: null };
loaderDataMock = {
items: [],
nextCursor: null,
rateLimited: false,
retryAfterSeconds: null,
apiError: false,
};
});
it("rejects skill family filter in search state", async () => {
@@ -223,6 +230,36 @@ 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,
});
});
+80
View File
@@ -0,0 +1,80 @@
/* @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");
});
});
+50
View File
@@ -0,0 +1,50 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { createRef, type ComponentProps } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { SkillsToolbar } from '../routes/skills/-SkillsToolbar';
function renderToolbar(overrides?: Partial<ComponentProps<typeof SkillsToolbar>>) {
return render(
<SkillsToolbar
searchInputRef={createRef<HTMLInputElement>()}
query=""
hasQuery={false}
sort="downloads"
dir="desc"
view="list"
highlightedOnly={false}
nonSuspiciousOnly={false}
capabilityTag={undefined}
onQueryChange={vi.fn()}
onToggleHighlighted={vi.fn()}
onToggleNonSuspicious={vi.fn()}
onCapabilityTagChange={vi.fn()}
onSortChange={vi.fn()}
onToggleDir={vi.fn()}
onToggleView={vi.fn()}
{...overrides}
/>,
);
}
describe('SkillsToolbar', () => {
it('keeps filter chips on a dark-mode surface', () => {
renderToolbar();
const staffPicksButton = screen.getByRole('button', { name: 'Staff Picks' });
expect(staffPicksButton.className).toContain('dark:bg-[rgba(14,28,37,0.84)]');
expect(staffPicksButton.className).toContain('dark:text-[rgba(245,238,232,0.88)]');
});
it('uses a readable active color treatment in dark mode', () => {
renderToolbar({ highlightedOnly: true });
const staffPicksButton = screen.getByRole('button', { name: 'Staff Picks' });
expect(staffPicksButton.getAttribute('aria-pressed')).toBe('true');
expect(staffPicksButton.className).toContain('dark:bg-[rgba(255,131,95,0.14)]');
expect(staffPicksButton.className).toContain('dark:text-[#ffd5c9]');
});
});
+2 -2
View File
@@ -4,9 +4,9 @@ import {
MessageSquare,
Package,
Plug,
RefreshCw,
Shield,
Wrench,
Zap,
} from "lucide-react";
import type { SkillCategory } from "../lib/categories";
@@ -39,7 +39,7 @@ const CATEGORY_ICONS: Record<string, React.ReactNode> = {
"dev-tools": <Wrench size={15} />,
data: <Database size={15} />,
security: <Shield size={15} />,
automation: <Zap size={15} />,
automation: <RefreshCw size={15} />,
other: <Package size={15} />,
};
+1 -17
View File
@@ -1,18 +1,14 @@
import { Link } from "@tanstack/react-router";
import { FOOTER_NAV_SECTIONS } from "../lib/nav-items";
import { getSiteName } from "../lib/site";
export function Footer() {
const siteName = getSiteName();
return (
<footer className="site-footer" role="contentinfo">
<div className="site-footer-inner">
<div className="site-footer-divider" aria-hidden="true" />
<div className="footer-grid">
{FOOTER_NAV_SECTIONS.map((section) => (
<div key={section.title} className="footer-col">
<h4 className="footer-col-title">{section.title}</h4>
{section.items.map((item) => {
{section.items.filter((item) => item.featureFlag !== false).map((item) => {
if (item.kind === "link") {
return (
<Link key={item.label} to={item.to} search={item.search ?? {}}>
@@ -33,18 +29,6 @@ export function Footer() {
</div>
))}
</div>
<div className="footer-bottom">
<span>
{siteName} An{" "}
<a href="https://openclaw.ai" target="_blank" rel="noreferrer">
OpenClaw
</a>{" "}
project by{" "}
<a href="https://steipete.me" target="_blank" rel="noreferrer">
Peter Steinberger
</a>
</span>
</div>
</div>
</footer>
);
+60 -43
View File
@@ -12,7 +12,7 @@ import {
} from "../lib/nav-items";
import { isModerator } from "../lib/roles";
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
import { applyTheme, THEME_OPTIONS, useThemeMode } from "../lib/theme";
import { applyTheme, useThemeMode } from "../lib/theme";
import { startThemeTransition } from "../lib/theme-transition";
import { setAuthError, useAuthError } from "../lib/useAuthError";
import { useAuthStatus } from "../lib/useAuthStatus";
@@ -40,10 +40,12 @@ const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?:
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, setTheme } = useThemeMode();
const { theme, mode, setMode } = useThemeMode();
const toggleRef = useRef<HTMLDivElement | null>(null);
const siteMode = getSiteMode();
const siteName = useMemo(() => getSiteName(siteMode), [siteMode]);
@@ -69,7 +71,7 @@ export default function Header() {
const [navSearchQuery, setNavSearchQuery] = useState("");
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const themeLabel = THEME_OPTIONS.find((option) => option.value === theme)?.label ?? "Claw";
const ThemeModeIcon = getThemeModeIcon(mode);
const setThemeMode = (next: "system" | "light" | "dark") => {
startThemeTransition({
@@ -84,9 +86,10 @@ export default function Header() {
});
};
const setThemeFamily = (nextTheme: string) => {
applyTheme(mode, nextTheme);
setTheme(nextTheme);
const cycleThemeMode = () => {
const currentIndex = Math.max(0, THEME_MODE_SEQUENCE.indexOf(mode));
const nextMode = THEME_MODE_SEQUENCE[(currentIndex + 1) % THEME_MODE_SEQUENCE.length] ?? "system";
setThemeMode(nextMode);
};
const handleNavSearch = (e: React.FormEvent) => {
@@ -94,8 +97,16 @@ export default function Header() {
const q = navSearchQuery.trim();
if (!q) return;
void navigate({
to: "/search",
search: { q, type: undefined },
to: isSoulMode ? "/souls" : "/search",
search: isSoulMode
? {
q,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}
: { q, type: undefined },
});
setNavSearchQuery("");
setMobileSearchOpen(false);
@@ -118,12 +129,29 @@ export default function Header() {
</button>
<SheetContent side="left" className="mobile-nav-sheet">
<SheetHeader className="pr-10">
<SheetTitle>{siteName}</SheetTitle>
<SheetTitle>
<span className="mobile-nav-brand">
<span className="mobile-nav-brand-mark" aria-hidden="true">
<img
src="/clawd-logo.png"
alt=""
aria-hidden="true"
className="mobile-nav-brand-mark-image"
/>
</span>
<span className="mobile-nav-brand-name">{siteName}</span>
</span>
</SheetTitle>
<SheetDescription>
Browse sections, switch theme, and access account actions.
</SheetDescription>
</SheetHeader>
<div className="mobile-nav-section">
<SheetClose asChild>
<Link to="/" className="mobile-nav-link">
Home
</Link>
</SheetClose>
{isSoulMode ? (
<SheetClose asChild>
<a href={clawHubUrl} className="mobile-nav-link">
@@ -146,23 +174,6 @@ export default function Header() {
</SheetClose>
))}
</div>
<div className="mobile-nav-section">
<div className="mobile-nav-section-title">Theme family</div>
{THEME_OPTIONS.map((option) => (
<button
key={option.value}
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeFamily(option.value);
setMobileMenuOpen(false);
}}
>
<span>{option.label}</span>
{theme === option.value ? <span className="mobile-nav-meta">Selected</span> : null}
</button>
))}
</div>
<div className="mobile-nav-section">
<div className="mobile-nav-section-title">Theme mode</div>
<button
@@ -236,22 +247,16 @@ export default function Header() {
<Search size={18} aria-hidden="true" />
</button>
<div className="theme-toggle" ref={toggleRef}>
<div className="theme-picker-desktop" aria-label={`Theme family, current ${themeLabel}`}>
<span className="theme-picker-label">Theme</span>
<div className="theme-family-toggle" role="group" aria-label="Theme family">
{THEME_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className="theme-family-button"
data-state={theme === option.value ? "on" : "off"}
aria-pressed={theme === option.value}
onClick={() => setThemeFamily(option.value)}
>
{option.label}
</button>
))}
</div>
<div className="theme-cycle-group" aria-label="Theme controls">
<button
type="button"
className="theme-cycle-button theme-cycle-button-mode"
onClick={cycleThemeMode}
aria-label={`Cycle theme mode. Current: ${mode}`}
title={`Theme mode: ${mode}`}
>
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<ToggleGroup
className="theme-mode-toggle"
@@ -261,7 +266,7 @@ export default function Header() {
if (!value) return;
setThemeMode(value as "system" | "light" | "dark");
}}
aria-label={`Theme mode, ${themeLabel} preset`}
aria-label="Theme mode"
>
<ToggleGroupItem value="system" aria-label="System theme">
<Monitor className="h-4 w-4" aria-hidden="true" />
@@ -410,3 +415,15 @@ 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
@@ -143,7 +143,7 @@ export function SkillCommentsPanel({ skillId, isAuthenticated, me }: SkillCommen
comments.map((entry) => (
<div
key={entry.comment._id}
className="flex gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-3"
className="comment-entry flex gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-3"
>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<strong className="text-sm">
+60 -3
View File
@@ -1,10 +1,13 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useEffect } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { SkillDiffCard } from "./SkillDiffCard";
const getFileTextMock = vi.fn();
let diffEditorMounts = 0;
let diffEditorUnmounts = 0;
vi.mock("convex/react", () => ({
useAction: () => getFileTextMock,
@@ -18,15 +21,37 @@ vi.mock("@monaco-editor/react", () => ({
className?: string;
options?: { renderSideBySide?: boolean; useInlineViewWhenSpaceIsLimited?: boolean };
}) => (
<MockDiffEditor
className={className}
options={options}
/>
),
useMonaco: () => null,
}));
function MockDiffEditor({
className,
options,
}: {
className?: string;
options?: { renderSideBySide?: boolean; useInlineViewWhenSpaceIsLimited?: boolean };
}) {
useEffect(() => {
diffEditorMounts += 1;
return () => {
diffEditorUnmounts += 1;
};
}, []);
return (
<div
className={className}
data-inline-fallback={String(options?.useInlineViewWhenSpaceIsLimited)}
data-side-by-side={String(options?.renderSideBySide)}
data-testid="diff-editor"
/>
),
useMonaco: () => null,
}));
);
}
function installMatchMedia(matches: boolean) {
Object.defineProperty(window, "matchMedia", {
@@ -65,6 +90,8 @@ describe("SkillDiffCard", () => {
beforeEach(() => {
getFileTextMock.mockReset();
getFileTextMock.mockResolvedValue({ text: "content" });
diffEditorMounts = 0;
diffEditorUnmounts = 0;
});
it("defaults to inline mode on narrow screens", async () => {
@@ -108,4 +135,34 @@ describe("SkillDiffCard", () => {
});
expect(screen.getByRole("button", { name: "Side-by-side" }).className).toContain("is-active");
});
it("keeps the diff editor mounted when toggling view mode", async () => {
installMatchMedia(false);
render(
<SkillDiffCard
skill={skill}
versions={[
makeVersion("skillVersions:1", "1.0.1"),
makeVersion("skillVersions:2", "1.0.2"),
]}
/>,
);
await waitFor(() => {
expect(screen.getByTestId("diff-editor")).toBeTruthy();
});
expect(diffEditorMounts).toBe(1);
expect(diffEditorUnmounts).toBe(0);
fireEvent.click(screen.getByRole("button", { name: "Inline" }));
await waitFor(() => {
expect(screen.getByTestId("diff-editor").getAttribute("data-side-by-side")).toBe("false");
});
expect(diffEditorMounts).toBe(1);
expect(diffEditorUnmounts).toBe(0);
});
});
+1 -2
View File
@@ -38,7 +38,7 @@ type SizeWarning = {
};
const EMPTY_DIFF_TEXT = "";
const MOBILE_DIFF_BREAKPOINT = 860;
const MOBILE_DIFF_BREAKPOINT = 768;
function getDefaultViewMode() {
if (typeof window === "undefined") return "split";
@@ -402,7 +402,6 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
) : (
<ClientOnly fallback={<div className="diff-empty">Preparing diff</div>}>
<DiffEditor
key={`diff-${viewMode}`}
className={`diff-monaco diff-monaco-${viewMode}`}
original={leftText}
modified={rightText}
+4 -2
View File
@@ -7,6 +7,7 @@ import { formatCompactStat, formatSkillStatsTriplet } from "../lib/numberFormat"
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { type LlmAnalysis, SecurityScanResults } from "./SkillSecurityScanResults";
import { SkillInstallCard } from "./SkillInstallCard";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { UserBadge } from "./UserBadge";
@@ -110,8 +111,8 @@ export function SkillHeader({
onTagSubmit,
onTagDelete,
tagVersions,
clawdis: _clawdis,
osLabels: _osLabels,
clawdis,
osLabels,
}: SkillHeaderProps) {
const formattedStats = formatSkillStatsTriplet(skill.stats);
const suppressScanResults =
@@ -354,6 +355,7 @@ export function SkillHeader({
) : null}
</div>
) : null}
<SkillInstallCard clawdis={clawdis} osLabels={osLabels} />
</div>
<div className="skill-tag-row">
@@ -4,11 +4,20 @@ import { SecurityScanResults } from "./SkillSecurityScanResults";
describe("SecurityScanResults static guidance", () => {
it("renders capability-only states without scanner verdicts", () => {
render(<SecurityScanResults capabilityTags={["posts-externally", "requires-oauth-token"]} />);
render(
<SecurityScanResults
capabilityTags={[
"posts-externally",
"requires-oauth-token",
"requires-sensitive-credentials",
]}
/>,
);
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", () => {
@@ -14,6 +14,7 @@ const SKILL_CAPABILITY_LABELS: Record<string, string> = {
"can-make-purchases": "Can make purchases",
"can-sign-transactions": "Can sign transactions",
"requires-oauth-token": "Requires OAuth token",
"requires-sensitive-credentials": "Requires sensitive credentials",
"posts-externally": "Posts externally",
};
+4 -4
View File
@@ -47,10 +47,10 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
variant === "outline" &&
"border border-[color:var(--border-ui)] bg-transparent text-[color:var(--ink)] hover:not-disabled:border-[color:var(--border-ui-hover)] hover:not-disabled:bg-[color:var(--surface)]",
// Size styles
size === "default" && "min-h-[44px] rounded-[var(--radius-pill)] px-4 py-[11px] text-sm",
size === "sm" && "min-h-[34px] rounded-[var(--radius-pill)] px-3 py-1.5 text-xs",
size === "lg" && "min-h-[52px] rounded-[var(--radius-pill)] px-6 py-3 text-base",
size === "icon" && "h-[44px] w-[44px] rounded-[var(--radius-pill)] p-0",
size === "default" && "min-h-[44px] rounded-[var(--r-btn)] px-4 py-[11px] text-sm",
size === "sm" && "min-h-[34px] rounded-[var(--r-btn)] px-3 py-1.5 text-xs",
size === "lg" && "min-h-[52px] rounded-[var(--r-btn)] px-6 py-3 text-base",
size === "icon" && "h-[44px] w-[44px] rounded-[var(--r-btn)] p-0",
className,
)}
disabled={disabled || loading}
+15
View File
@@ -0,0 +1,15 @@
import { getRuntimeEnv } from "./runtimeEnv";
/**
* Feature flags controlled via VITE_FEATURE_* env vars.
* Default values are the fallback when the env var is unset.
*/
function flag(name: string, defaultValue: boolean): boolean {
const raw = getRuntimeEnv(name);
if (raw === undefined) return defaultValue;
return raw === "true" || raw === "1";
}
/** Show the Souls section (nav, footer, homepage category, routes). Default: false */
export const FEATURE_SOULS = flag("VITE_FEATURE_SOULS", false);
+11 -13
View File
@@ -1,3 +1,5 @@
import { FEATURE_SOULS } from "./features";
/**
* Shared navigation configuration used by Header and Footer to eliminate
* triple duplication of nav link definitions.
@@ -25,6 +27,8 @@ export interface NavItem {
soulModeHide: boolean;
/** Additional path prefixes that should also highlight this nav item (e.g. /skill for /skills) */
activePathPrefixes?: string[];
/** Feature flag that must be truthy for this item to show */
featureFlag?: boolean;
}
// ---------------------------------------------------------------------------
@@ -79,7 +83,7 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
soulModeHide: false,
activePathPrefixes: ["/plugin/"],
},
{
@@ -90,9 +94,9 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
authRequired: false,
staffOnly: false,
soulModeOnly: false,
// In soul-mode this is the primary tab; in skills-mode it is also shown.
soulModeHide: false,
activePathPrefixes: ["/soul/"],
featureFlag: FEATURE_SOULS,
},
];
@@ -155,9 +159,9 @@ export interface FooterNavSection {
}
export type FooterNavItem =
| { kind: "link"; label: string; to: string; search?: Record<string, unknown> }
| { kind: "external"; label: string; href: string }
| { kind: "text"; label: string };
| { kind: "link"; label: string; to: string; search?: Record<string, unknown>; featureFlag?: boolean }
| { kind: "external"; label: string; href: string; featureFlag?: boolean }
| { kind: "text"; label: string; featureFlag?: boolean };
export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
{
@@ -165,7 +169,7 @@ export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
items: [
{ kind: "link", label: "Skills", to: "/skills", search: SKILLS_SEARCH },
{ kind: "link", label: "Plugins", to: "/plugins" },
{ kind: "link", label: "Souls", to: "/souls", search: SOULS_SEARCH },
{ kind: "link", label: "Souls", to: "/souls", search: SOULS_SEARCH, featureFlag: FEATURE_SOULS },
],
},
{
@@ -190,25 +194,18 @@ export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
sourceRepo: undefined,
},
},
{
kind: "external",
label: "Documentation",
href: "https://github.com/openclaw/clawhub",
},
],
},
{
title: "Community",
items: [
{ kind: "external", label: "GitHub", href: "https://github.com/openclaw/clawhub" },
{ kind: "link", label: "About", to: "/about" },
{ kind: "external", label: "OpenClaw", href: "https://openclaw.ai" },
],
},
{
title: "Platform",
items: [
{ kind: "text", label: "MIT Licensed" },
{ kind: "external", label: "Deployed on Vercel", href: "https://vercel.com" },
{ kind: "external", label: "Powered by Convex", href: "https://www.convex.dev" },
],
@@ -229,6 +226,7 @@ export function filterNavItems(
if (item.soulModeHide && ctx.isSoulMode) return false;
if (item.authRequired && !ctx.isAuthenticated) return false;
if (item.staffOnly && !ctx.isStaff) return false;
if (item.featureFlag === false) return false;
return true;
});
}
+82 -68
View File
@@ -128,15 +128,36 @@ function normalizeApiPath(path: string) {
return path.startsWith("/") ? path : `/${path}`;
}
function resolveAbsoluteBaseUrl(...candidates: Array<string | undefined>) {
for (const candidate of candidates) {
const value = candidate?.trim();
if (!value) continue;
try {
return new URL(value).toString();
} catch {
continue;
}
}
return null;
}
async function packageApiUrl(path: string) {
const normalizedPath = normalizeApiPath(path);
if (typeof window !== "undefined") {
// In production, Vercel rewrites /api/* to the Convex site, so relative
// paths work. In local dev, Nitro intercepts the request before Vite's
// proxy, so we must use the Convex site URL directly.
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL");
if (convexSiteUrl && window.location.hostname === "localhost") {
return new URL(normalizedPath, convexSiteUrl);
const convexClientBaseUrl = resolveAbsoluteBaseUrl(
getRuntimeEnv("VITE_CONVEX_SITE_URL"),
getRuntimeEnv("VITE_CONVEX_URL"),
);
if (
convexClientBaseUrl &&
(window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1" ||
window.location.hostname === "0.0.0.0")
) {
return new URL(normalizedPath, convexClientBaseUrl);
}
return new URL(normalizedPath, window.location.origin);
}
@@ -144,7 +165,11 @@ async function packageApiUrl(path: string) {
// In production, Vercel rewrites /api/* but SSR loaders run server-side
// where the rewrite doesn't apply. Using getRequestUrl() would loop back
// into TanStack Start / Nitro, which rejects non-HTML requests.
const base = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? getRequiredRuntimeEnv("VITE_CONVEX_URL");
const base =
resolveAbsoluteBaseUrl(
getRuntimeEnv("VITE_CONVEX_SITE_URL"),
getRuntimeEnv("VITE_CONVEX_URL"),
) ?? getRequiredRuntimeEnv("VITE_CONVEX_URL");
return new URL(normalizedPath, base);
}
@@ -277,51 +302,32 @@ export async function fetchPluginCatalog(params: {
executesCode?: boolean;
limit?: number;
}): Promise<PluginCatalogResult> {
try {
if (params.family) {
const response = await fetchPackages({
q: params.q,
cursor: params.cursor,
family: params.family,
isOfficial: params.isOfficial,
executesCode: params.executesCode,
limit: params.limit,
});
if (hasOwnProperty(response, "results") && Array.isArray(response.results)) {
return {
items: response.results.map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
nextCursor: null,
};
}
const browseResponse = response as PackageCatalogBrowseResponse;
if (params.family) {
const response = await fetchPackages({
q: params.q,
cursor: params.cursor,
family: params.family,
isOfficial: params.isOfficial,
executesCode: params.executesCode,
limit: params.limit,
});
if (hasOwnProperty(response, "results") && Array.isArray(response.results)) {
return {
items: browseResponse?.items ?? [],
nextCursor: browseResponse?.nextCursor ?? null,
};
}
if (params.q?.trim()) {
const url = await packageApiUrl(`${ApiRoutes.plugins}/search`);
url.searchParams.set("q", params.q.trim());
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
}
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
const response = await fetchJson<{
results?: Array<{ score: number; package: PackageListItem }>;
}>(url);
return {
items: (response?.results ?? []).map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
items: response.results.map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
nextCursor: null,
};
}
const url = await packageApiUrl(ApiRoutes.plugins);
if (params.cursor) url.searchParams.set("cursor", params.cursor);
const browseResponse = response as PackageCatalogBrowseResponse;
return {
items: browseResponse?.items ?? [],
nextCursor: browseResponse?.nextCursor ?? null,
};
}
if (params.q?.trim()) {
const url = await packageApiUrl(`${ApiRoutes.plugins}/search`);
url.searchParams.set("q", params.q.trim());
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
@@ -329,29 +335,39 @@ export async function fetchPluginCatalog(params: {
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
const result = await fetchJson<PluginCatalogResult>(url);
const response = await fetchJson<{
results?: Array<{ score: number; package: PackageListItem }>;
}>(url);
return {
items: result?.items ?? [],
nextCursor: result?.nextCursor ?? null,
items: (response?.results ?? []).map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
nextCursor: null,
};
} catch {
// Return empty result on API error to prevent SSR crashes
return { items: [], nextCursor: null };
}
const url = await packageApiUrl(ApiRoutes.plugins);
if (params.cursor) url.searchParams.set("cursor", params.cursor);
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
}
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
const result = await fetchJson<PluginCatalogResult>(url);
return {
items: result?.items ?? [],
nextCursor: result?.nextCursor ?? null,
};
}
export async function fetchPackageDetail(name: string): Promise<PackageDetailResponse> {
try {
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}`);
const response = await packageFetch(url, "application/json");
if (response.status === 404 || !response.ok) {
return { package: null, owner: null };
}
return (await response.json()) as PackageDetailResponse;
} catch {
// Return empty result on API error to prevent SSR crashes
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}`);
const response = await packageFetch(url, "application/json");
if (response.status === 404) {
return { package: null, owner: null };
}
if (!response.ok) throw await createPackageApiError(response);
return (await response.json()) as PackageDetailResponse;
}
export async function fetchPackageVersion(name: string, version: string): Promise<PackageVersionDetail | null> {
@@ -367,15 +383,13 @@ export async function fetchPackageVersion(name: string, version: string): Promis
}
export async function fetchPackageReadme(name: string, version?: string | null): Promise<string | null> {
try {
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
url.searchParams.set("path", "README.md");
if (version) url.searchParams.set("version", version);
const response = await packageFetch(url, "text/plain");
if (response.ok) return await response.text();
return null;
} catch {
// Return null on API error to prevent SSR crashes
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
url.searchParams.set("path", "README.md");
if (version) url.searchParams.set("version", version);
const response = await packageFetch(url, "text/plain");
if (response.ok) return await response.text();
if (response.status === 404 || response.status === 423) {
return null;
}
throw await createPackageApiError(response);
}
+121
View File
@@ -0,0 +1,121 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { usePreferences, getStoredPreferencesSnapshot } from "./preferences";
const PREFERENCES_KEY = "clawhub-preferences";
function PreferencesProbe() {
const { preferences, updatePreference, isAdvancedMode } = usePreferences();
return (
<div>
<div data-testid="density">{preferences.layoutDensity}</div>
<div data-testid="advanced">{String(isAdvancedMode)}</div>
<button
type="button"
onClick={() => updatePreference("advancedMode", !preferences.advancedMode)}
>
Toggle advanced
</button>
</div>
);
}
describe("preferences store", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("returns a stable snapshot when storage has not changed", () => {
const first = getStoredPreferencesSnapshot();
const second = getStoredPreferencesSnapshot();
expect(first).toBe(second);
expect(first.layoutDensity).toBe("comfortable");
});
it("falls back to defaults when localStorage reads throw", () => {
const getItemSpy = vi
.spyOn(Storage.prototype, "getItem")
.mockImplementation(() => {
throw new DOMException("blocked", "SecurityError");
});
render(<PreferencesProbe />);
expect(screen.getByTestId("density").textContent).toBe("comfortable");
expect(screen.getByTestId("advanced").textContent).toBe("false");
getItemSpy.mockRestore();
});
it("resets to defaults when the preference key is cleared in another tab", () => {
window.localStorage.setItem(
PREFERENCES_KEY,
JSON.stringify({
advancedMode: true,
layoutDensity: "compact",
}),
);
render(<PreferencesProbe />);
expect(screen.getByTestId("advanced").textContent).toBe("true");
expect(screen.getByTestId("density").textContent).toBe("compact");
window.localStorage.removeItem(PREFERENCES_KEY);
act(() => {
const event = Object.assign(new Event("storage"), {
key: PREFERENCES_KEY,
newValue: null,
oldValue: JSON.stringify({
advancedMode: true,
layoutDensity: "compact",
}),
storageArea: window.localStorage,
});
window.dispatchEvent(event);
});
expect(screen.getByTestId("advanced").textContent).toBe("false");
expect(screen.getByTestId("density").textContent).toBe("comfortable");
});
it("re-renders cleanly after a preference update", () => {
window.localStorage.setItem(
PREFERENCES_KEY,
JSON.stringify({
advancedMode: false,
}),
);
render(<PreferencesProbe />);
expect(screen.getByTestId("advanced").textContent).toBe("false");
fireEvent.click(screen.getByRole("button", { name: /toggle advanced/i }));
expect(screen.getByTestId("advanced").textContent).toBe("true");
expect(JSON.parse(window.localStorage.getItem(PREFERENCES_KEY) ?? "{}")).toMatchObject({
advancedMode: true,
});
});
it("does not cache a preference change when storage writes fail", () => {
const setItemSpy = vi
.spyOn(Storage.prototype, "setItem")
.mockImplementation(() => {
throw new DOMException("quota", "QuotaExceededError");
});
render(<PreferencesProbe />);
expect(screen.getByTestId("advanced").textContent).toBe("false");
fireEvent.click(screen.getByRole("button", { name: /toggle advanced/i }));
expect(screen.getByTestId("advanced").textContent).toBe("false");
expect(window.localStorage.getItem(PREFERENCES_KEY)).toBeNull();
setItemSpy.mockRestore();
});
});
+79 -19
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useSyncExternalStore } from "react";
const PREFERENCES_KEY = "clawhub-preferences";
@@ -66,36 +66,95 @@ const defaultPreferences: UserPreferences = {
// Simple event emitter for cross-tab sync
const listeners = new Set<() => void>();
let cachedPreferencesRaw: string | null = null;
let cachedPreferencesSnapshot: UserPreferences = defaultPreferences;
let hasCachedPreferences = false;
let removeStorageListener: (() => void) | null = null;
function normalizePreferences(parsed: Partial<UserPreferences> | null): UserPreferences {
if (!parsed) return defaultPreferences;
return { ...defaultPreferences, ...parsed };
}
function parsePreferences(raw: string | null): UserPreferences {
if (!raw) return defaultPreferences;
try {
const parsed = JSON.parse(raw) as Partial<UserPreferences>;
return normalizePreferences(parsed);
} catch {
return defaultPreferences;
}
}
function readStoredPreferences(): UserPreferences {
if (typeof window === "undefined") return defaultPreferences;
try {
const stored = window.localStorage.getItem(PREFERENCES_KEY);
if (hasCachedPreferences && stored === cachedPreferencesRaw) {
return cachedPreferencesSnapshot;
}
cachedPreferencesRaw = stored;
cachedPreferencesSnapshot = parsePreferences(stored);
hasCachedPreferences = true;
return cachedPreferencesSnapshot;
} catch {
cachedPreferencesRaw = null;
cachedPreferencesSnapshot = defaultPreferences;
hasCachedPreferences = true;
return cachedPreferencesSnapshot;
}
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
if (typeof window !== "undefined" && !removeStorageListener) {
const handleStorage = (event: StorageEvent) => {
if (event.storageArea !== window.localStorage || event.key !== PREFERENCES_KEY) {
return;
}
cachedPreferencesRaw = event.newValue;
cachedPreferencesSnapshot = parsePreferences(event.newValue);
hasCachedPreferences = true;
notifyListeners();
};
window.addEventListener("storage", handleStorage);
removeStorageListener = () => {
window.removeEventListener("storage", handleStorage);
removeStorageListener = null;
};
}
return () => {
listeners.delete(listener);
if (listeners.size === 0 && removeStorageListener) {
removeStorageListener();
}
};
}
function notifyListeners() {
listeners.forEach((listener) => listener());
}
function getStoredPreferences(): UserPreferences {
if (typeof window === "undefined") return defaultPreferences;
try {
const stored = window.localStorage.getItem(PREFERENCES_KEY);
if (!stored) return defaultPreferences;
const parsed = JSON.parse(stored) as Partial<UserPreferences>;
return { ...defaultPreferences, ...parsed };
} catch {
return defaultPreferences;
}
}
function savePreferences(prefs: UserPreferences) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(PREFERENCES_KEY, JSON.stringify(prefs));
notifyListeners();
const serialized = JSON.stringify(prefs);
window.localStorage.setItem(PREFERENCES_KEY, serialized);
cachedPreferencesRaw = serialized;
cachedPreferencesSnapshot = prefs;
hasCachedPreferences = true;
} catch {
// Storage might be full or disabled
}
notifyListeners();
}
// Server snapshot for SSR
@@ -106,7 +165,7 @@ function getServerSnapshot(): UserPreferences {
export function usePreferences() {
const preferences = useSyncExternalStore(
subscribe,
getStoredPreferences,
readStoredPreferences,
getServerSnapshot
);
@@ -114,13 +173,13 @@ export function usePreferences() {
key: K,
value: UserPreferences[K]
) => {
const current = getStoredPreferences();
const current = readStoredPreferences();
const updated = { ...current, [key]: value };
savePreferences(updated);
}, []);
const updatePreferences = useCallback((updates: Partial<UserPreferences>) => {
const current = getStoredPreferences();
const current = readStoredPreferences();
const updated = { ...current, ...updates };
savePreferences(updated);
}, []);
@@ -163,3 +222,4 @@ export function usePreferences() {
}
export { defaultPreferences };
export { readStoredPreferences as getStoredPreferencesSnapshot };
+4 -9
View File
@@ -8,7 +8,7 @@ import {
type CustomThemeData,
} from './customTheme';
export type ThemeName = 'claw' | 'hub';
export type ThemeName = 'claw';
export type ThemeMode = 'system' | 'light' | 'dark';
export type ResolvedTheme = 'light' | 'dark';
@@ -29,16 +29,11 @@ export const THEME_OPTIONS: Array<{ value: ThemeName; label: string; description
label: 'Claw',
description: 'OpenClaw black, white, and red.',
},
{
value: 'hub',
label: 'Hub',
description: 'Marketplace monochrome index with terminal-style contrast.',
},
];
export const THEME_FAMILY_OPTIONS = THEME_OPTIONS;
const VALID_THEME_NAMES = new Set<ThemeName>(['claw', 'hub']);
const VALID_THEME_NAMES = new Set<ThemeName>(['claw']);
const VALID_THEME_MODES = new Set<ThemeMode>(['system', 'light', 'dark']);
const LEGACY_MAP: Record<string, ThemeSelection> = {
@@ -51,8 +46,8 @@ const LEGACY_MAP: Record<string, ThemeSelection> = {
landingTheme: { theme: 'claw', mode: 'dark' },
newTheme: { theme: 'claw', mode: 'dark' },
openknot: { theme: 'claw', mode: 'dark' },
fieldmanual: { theme: 'hub', mode: 'dark' },
clawdash: { theme: 'hub', mode: 'light' },
fieldmanual: { theme: 'claw', mode: 'dark' },
clawdash: { theme: 'claw', mode: 'light' },
};
function parseThemeSelection(themeRaw: unknown, modeRaw: unknown): ThemeSelection {
+1 -1
View File
@@ -8,7 +8,7 @@ import { getUserFacingConvexError } from "./convexError";
export async function uploadFile(uploadUrl: string, file: File) {
const path = file.webkitRelativePath || file.name;
const contentType =
normalizeTextContentType(path, file.type) ?? file.type ?? "application/octet-stream";
normalizeTextContentType(path, file.type) || file.type || "application/octet-stream";
const response = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": contentType },
-12
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

+8 -6
View File
@@ -10,13 +10,15 @@ import Header from "../components/Header";
import { getSiteDescription, getSiteMode, getSiteName, getSiteUrlForMode } from "../lib/site";
import appCss from "../styles.css?url";
const OG_IMAGE_VERSION = "20260420-12";
export const Route = createRootRoute({
head: () => {
const mode = getSiteMode();
const siteName = getSiteName(mode);
const siteDescription = getSiteDescription(mode);
const siteUrl = getSiteUrlForMode(mode);
const ogImage = `${siteUrl}/og.png`;
const ogImage = `${siteUrl}/og.png?v=${OG_IMAGE_VERSION}`;
return {
meta: [
@@ -97,11 +99,6 @@ export const Route = createRootRoute({
href: "/favicon.ico",
type: "image/x-icon",
},
{
rel: "icon",
href: "/logo.svg",
type: "image/svg+xml",
},
{
rel: "apple-touch-icon",
href: "/logo192.png",
@@ -122,6 +119,11 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<html lang="en">
<head>
<HeadContent />
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var d=document.documentElement,s='clawhub-theme-selection',k='clawhub-theme',n='clawhub-theme-name',l='clawdhub-theme';var sel;try{var raw=localStorage.getItem(s);if(raw){sel=JSON.parse(raw)}}catch(e){}if(!sel){var m=localStorage.getItem(k),t=localStorage.getItem(n);if(m||t){sel={theme:t||'claw',mode:m||'system'}}else{var lg=localStorage.getItem(l);if(lg){var map={dark:'dark',light:'light',system:'system',defaultTheme:'dark',docsTheme:'light',lightTheme:'dark',landingTheme:'dark',newTheme:'dark',openknot:'dark',fieldmanual:'dark',clawdash:'light'};sel={theme:'claw',mode:map[lg]||'system'}}}}if(!sel)sel={theme:'claw',mode:'system'};var themes=['claw'],modes=['system','light','dark'];if(themes.indexOf(sel.theme)<0)sel.theme='claw';if(modes.indexOf(sel.mode)<0)sel.mode='system';var resolved=sel.mode==='system'?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):sel.mode;d.dataset.theme=resolved;d.dataset.themeResolved=resolved;d.dataset.themeMode=sel.mode;d.dataset.themeFamily=sel.theme;if(resolved==='dark')d.classList.add('dark');else d.classList.remove('dark')}catch(e){}})()`,
}}
/>
</head>
<body>
<AppProviders>
+132 -80
View File
@@ -1,41 +1,72 @@
import { createFileRoute, Link } from '@tanstack/react-router';
import type { LucideIcon } from 'lucide-react';
import {
Banknote,
Drama,
Eye,
EyeOff,
ImageOff,
ShieldOff,
UserX,
} from 'lucide-react';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { getSiteMode, getSiteName, getSiteUrlForMode } from '../lib/site';
const prohibitedCategories = [
export function renderWithInlineCode(text: string): (string | JSX.Element)[] {
const parts = text.split(/(`[^`]+`)/g);
return parts.map((part, i) => {
if (part.startsWith('`') && part.endsWith('`')) {
return (
<code key={i} className="about-inline-code">
{part.slice(1, -1)}
</code>
);
}
return part;
});
}
const prohibitedCategories: { title: string; icon: LucideIcon; examples: string }[] = [
{
title: 'Bypass and unauthorized access',
icon: ShieldOff,
examples:
'Auth bypass, account takeover, CAPTCHA bypass, Cloudflare or anti-bot evasion, rate-limit bypass, reusable session theft, live call or agent takeover.',
},
{
title: 'Platform abuse and ban evasion',
icon: UserX,
examples:
'Stealth accounts after bans, account warming/farming, fake engagement, multi-account automation, spam posting, marketplace or social automation built to avoid detection.',
},
{
title: 'Fraud and deception',
icon: Banknote,
examples:
'Fake certificates, fake invoices, deceptive payment flows, fake social proof, scam outreach, or synthetic-identity workflows built to create accounts for fraud.',
},
{
title: 'Privacy-invasive surveillance',
icon: Eye,
examples:
'Mass contact scraping for spam, doxxing, stalking, covert monitoring, biometric / face-matching workflows without clear consent, or buying, publishing, downloading, or operationalizing leaked data or breach dumps.',
},
{
title: 'Non-consensual impersonation',
icon: Drama,
examples:
'Face swap, digital twins, cloned influencers, fake personas, or other identity manipulation used to impersonate or mislead.',
},
{
title: 'Explicit sexual content',
icon: ImageOff,
examples:
'NSFW image, video, or text generation, especially wrappers around third-party APIs with safety checks disabled.',
},
{
title: 'Hidden or misleading execution',
icon: EyeOff,
examples:
'Obfuscated install commands, `curl | sh`, undeclared secret requirements, undeclared private-key use, or remote `npx @latest` execution without reviewability.',
},
@@ -84,88 +115,109 @@ export const Route = createFileRoute('/about')({
function AboutPage() {
return (
<main className="section about-page">
<div className="about-hero">
<div className="about-hero-copy">
<div className="skill-card-tags mb-3">
<Badge>About</Badge>
<Badge variant="accent">Policy</Badge>
<div className="about-bento">
<section className="about-panel about-panel-hero">
<div className="about-hero-copy">
<div className="skill-card-tags mb-3">
<Badge>About</Badge>
<Badge variant="accent">Policy</Badge>
</div>
<h1 className="about-title">What ClawHub will not host</h1>
<p className="about-lead">
ClawHub is for useful agent tooling, not abuse workflows. If a skill is built to
evade defenses, scam people, invade privacy, or enable non-consensual behavior, it
does not belong here.
</p>
</div>
<h1 className="about-title">What ClawHub will not host</h1>
<p className="about-lead">
ClawHub is for useful agent tooling, not abuse workflows. If a skill is built to evade
defenses, scam people, invade privacy, or enable non-consensual behavior, it does not
belong here.
</section>
<aside className="about-panel about-panel-callout">
<div className="about-callout">
<span className="about-callout-label">Moderation stance</span>
<p>
We judge end-to-end abuse patterns, not keyword theater. Useful tooling stays.
Predatory workflows get removed.
</p>
</div>
</aside>
<section className="about-panel about-panel-categories">
<div className="home-section-header">
<h2 className="home-section-title">Rejection Categories</h2>
</div>
<div className="about-grid">
{prohibitedCategories.map((category) => {
const Icon = category.icon;
return (
<article key={category.title} className="about-rule-card">
<div className="about-rule-card-header">
<div className="about-rule-card-icon">
<Icon size={18} />
</div>
<h3>{category.title}</h3>
</div>
<p>{renderWithInlineCode(category.examples)}</p>
</article>
);
})}
</div>
</section>
<section className="about-panel about-panel-patterns">
<div className="home-section-header">
<h2 className="home-section-title">Recent patterns we are explicitly not okay with</h2>
</div>
<div className="about-patterns">
{recentPatterns.map((pattern) => (
<div key={pattern} className="about-pattern">
{pattern}
</div>
))}
</div>
</section>
<section className="about-panel about-panel-enforcement">
<div>
<span className="about-callout-label">Enforcement</span>
<div className="management-sublist">
<div className="management-subitem">
We may hide, remove, or hard-delete violating skills.
</div>
<div className="management-subitem">
We may revoke tokens, soft-delete associated content, and ban repeat or severe
offenders.
</div>
<div className="management-subitem">
We do not guarantee warning-first enforcement for obvious abuse.
</div>
</div>
</div>
</section>
<section className="about-panel about-panel-actions">
<span className="about-callout-label">Next steps</span>
<p className="about-panel-copy">
If you are reviewing a borderline workflow, use the reviewer doc. If you are browsing,
stay in the public catalog.
</p>
</div>
<div className="about-callout">
<span className="about-callout-label">Moderation stance</span>
<p>
We judge end-to-end abuse patterns, not keyword theater. Useful tooling stays.
Predatory workflows get removed.
</p>
</div>
<div className="skill-card-tags">
<Button asChild variant="primary">
<Link to="/skills">
Browse Skills
</Link>
</Button>
<Button asChild>
<a
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
target="_blank"
rel="noreferrer"
>
Reviewer Doc
</a>
</Button>
</div>
</section>
</div>
<section className="about-section">
<div className="home-section-header">
<h2 className="home-section-title">Immediate rejection categories</h2>
</div>
<div className="about-grid">
{prohibitedCategories.map((category) => (
<article key={category.title} className="about-rule-card">
<h2>{category.title}</h2>
<p>{category.examples}</p>
</article>
))}
</div>
</section>
<section className="about-section">
<div className="home-section-header">
<h2 className="home-section-title">Recent patterns we are explicitly not okay with</h2>
</div>
<div className="about-patterns">
{recentPatterns.map((pattern) => (
<div key={pattern} className="about-pattern">
{pattern}
</div>
))}
</div>
</section>
<section className="about-enforcement">
<div>
<span className="about-callout-label">Enforcement</span>
<div className="management-sublist">
<div className="management-subitem">
We may hide, remove, or hard-delete violating skills.
</div>
<div className="management-subitem">
We may revoke tokens, soft-delete associated content, and ban repeat or severe
offenders.
</div>
<div className="management-subitem">
We do not guarantee warning-first enforcement for obvious abuse.
</div>
</div>
</div>
<div className="skill-card-tags">
<Button asChild variant="primary">
<Link to="/skills">
Browse Skills
</Link>
</Button>
<Button asChild>
<a
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
target="_blank"
rel="noreferrer"
>
Reviewer Doc
</a>
</Button>
</div>
</section>
</main>
);
}
+721 -472
View File
File diff suppressed because it is too large Load Diff
+42 -12
View File
@@ -14,6 +14,7 @@ import {
fetchPackageReadme,
fetchPackageVersion,
getPackageDownloadPath,
isRateLimitedPackageApiError,
type PackageDetailResponse,
type PackageVersionDetail,
} from "../../lib/packageApi";
@@ -35,7 +36,6 @@ type PluginDetailLoaderData = {
export const Route = createFileRoute("/plugins/$name")({
loader: async ({ params }): Promise<PluginDetailLoaderData> => {
// All fetch functions now handle errors internally and return null/empty on failure
const requestedName = params.name;
const candidateNames = requestedName.includes("/")
? [requestedName]
@@ -43,9 +43,25 @@ export const Route = createFileRoute("/plugins/$name")({
let resolvedName = requestedName;
let detail: PackageDetailResponse = { package: null, owner: null };
for (const candidateName of candidateNames) {
const candidateDetail = await fetchPackageDetail(candidateName);
let candidateDetail: PackageDetailResponse;
try {
candidateDetail = await fetchPackageDetail(candidateName);
} catch (error) {
if (isRateLimitedPackageApiError(error)) {
return {
detail: { package: null, owner: null },
version: null,
readme: null,
rateLimited: {
scope: "detail",
retryAfterSeconds: error.retryAfterSeconds,
},
};
}
throw error;
}
if (candidateDetail.package) {
detail = candidateDetail;
resolvedName = candidateName;
@@ -58,15 +74,29 @@ export const Route = createFileRoute("/plugins/$name")({
return { detail, version: null, readme: null, rateLimited: null };
}
// Fetch readme and version in parallel - functions handle errors internally
const [version, readme] = await Promise.all([
detail.package.latestVersion
? fetchPackageVersion(resolvedName, detail.package.latestVersion)
: Promise.resolve(null),
fetchPackageReadme(resolvedName),
]);
return { detail, version, readme, rateLimited: null };
try {
const [version, readme] = await Promise.all([
detail.package.latestVersion
? fetchPackageVersion(resolvedName, detail.package.latestVersion)
: Promise.resolve(null),
fetchPackageReadme(resolvedName),
]);
return { detail, version, readme, rateLimited: null };
} catch (error) {
if (isRateLimitedPackageApiError(error)) {
return {
detail,
version: null,
readme: null,
rateLimited: {
scope: "metadata",
retryAfterSeconds: error.retryAfterSeconds,
},
};
}
throw error;
}
},
head: ({ params, loaderData }) => ({
meta: [
+36 -17
View File
@@ -6,6 +6,7 @@ import { PluginListItem } from "../../components/PluginListItem";
import { Button } from "../../components/ui/button";
import {
fetchPluginCatalog,
isRateLimitedPackageApiError,
type PackageListItem,
} from "../../lib/packageApi";
@@ -55,24 +56,42 @@ export const Route = createFileRoute("/plugins/")({
}),
loaderDeps: ({ search }) => search,
loader: async ({ deps }): Promise<PluginsLoaderData> => {
// fetchPluginCatalog now handles errors internally and returns empty results
const data = await fetchPluginCatalog({
q: deps.q,
cursor: deps.q ? undefined : deps.cursor,
family: deps.family,
isOfficial: deps.verified,
executesCode: deps.executesCode,
limit: 50,
});
try {
const data = await fetchPluginCatalog({
q: deps.q,
cursor: deps.q ? undefined : deps.cursor,
family: deps.family,
isOfficial: deps.verified,
executesCode: deps.executesCode,
limit: 50,
});
const items = data?.items ?? [];
return {
items,
nextCursor: data?.nextCursor ?? null,
rateLimited: false,
retryAfterSeconds: null,
apiError: items.length === 0 && !deps.q && !deps.family && !deps.verified && !deps.executesCode,
};
return {
items: data?.items ?? [],
nextCursor: data?.nextCursor ?? null,
rateLimited: false,
retryAfterSeconds: null,
apiError: false,
};
} catch (error) {
if (isRateLimitedPackageApiError(error)) {
return {
items: [],
nextCursor: null,
rateLimited: true,
retryAfterSeconds: error.retryAfterSeconds,
apiError: false,
};
}
return {
items: [],
nextCursor: null,
rateLimited: false,
retryAfterSeconds: null,
apiError: true,
};
}
},
component: PluginsIndex,
});
+5 -1
View File
@@ -1,6 +1,6 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Search } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { PluginListItem } from "../components/PluginListItem";
import { SkillListItem } from "../components/SkillListItem";
import { UserListItem } from "../components/UserListItem";
@@ -35,6 +35,10 @@ function UnifiedSearchPage() {
const activeType = search.type ?? "all";
const [query, setQuery] = useState(search.q ?? "");
useEffect(() => {
setQuery(search.q ?? "");
}, [search.q]);
const { results, skillCount, pluginCount, userCount, isSearching } = useUnifiedSearch(
search.q ?? "",
activeType,
+92 -35
View File
@@ -9,9 +9,7 @@ import {
Moon,
RotateCcw,
Settings2,
Sparkles,
Sun,
Zap,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -52,7 +50,7 @@ import {
type ListViewMode,
usePreferences,
} from "../lib/preferences";
import { getThemeFamilyLabel, THEME_FAMILY_OPTIONS, useThemeMode } from "../lib/theme";
import { getThemeFamilyLabel, useThemeMode } from "../lib/theme";
export const Route = createFileRoute("/settings")({
component: Settings,
@@ -67,7 +65,6 @@ export function Settings() {
family: themeFamily,
importCustomTheme,
mode: themeMode,
setFamily: setThemeFamily,
setMode: setThemeMode,
clearCustomTheme,
} = useThemeMode();
@@ -91,11 +88,14 @@ export function Settings() {
handle: string;
displayName: string;
kind: "user" | "org";
image?: string | null;
bio?: string | null;
};
role: "owner" | "admin" | "publisher";
}>
| undefined;
const createOrg = useMutation(api.publishers.createOrg);
const updateOrgProfile = useMutation(api.publishers.updateProfile);
const addOrgMember = useMutation(api.publishers.addMember);
const removeOrgMember = useMutation(api.publishers.removeMember);
const [displayName, setDisplayName] = useState("");
@@ -105,6 +105,9 @@ export function Settings() {
const [orgHandle, setOrgHandle] = useState("");
const [orgDisplayName, setOrgDisplayName] = useState("");
const [selectedOrgHandle, setSelectedOrgHandle] = useState("");
const [selectedOrgDisplayName, setSelectedOrgDisplayName] = useState("");
const [selectedOrgBio, setSelectedOrgBio] = useState("");
const [selectedOrgImage, setSelectedOrgImage] = useState("");
const [memberHandle, setMemberHandle] = useState("");
const [memberRole, setMemberRole] = useState<"owner" | "admin" | "publisher">("publisher");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -144,6 +147,18 @@ export function Settings() {
}
}, [orgs, selectedOrgHandle]);
useEffect(() => {
if (!selectedOrg) {
setSelectedOrgDisplayName("");
setSelectedOrgBio("");
setSelectedOrgImage("");
return;
}
setSelectedOrgDisplayName(selectedOrg.publisher.displayName ?? "");
setSelectedOrgBio(selectedOrg.publisher.bio ?? "");
setSelectedOrgImage(selectedOrg.publisher.image ?? "");
}, [selectedOrg]);
if (!me) {
return (
<Container size="narrow" className="py-10">
@@ -205,6 +220,17 @@ export function Settings() {
toast.success("Removed custom theme");
}
async function onSaveOrgProfile() {
if (!selectedOrg) return;
await updateOrgProfile({
publisherId: selectedOrg.publisher._id,
displayName: selectedOrgDisplayName,
bio: selectedOrgBio || undefined,
image: selectedOrgImage || undefined,
});
toast.success("Organization updated");
}
return (
<Container size="narrow" className="py-10">
<main className="flex flex-col gap-6">
@@ -291,29 +317,6 @@ export function Settings() {
{/* Theme Section */}
<div className="space-y-3">
<Label id="theme" className="text-sm font-semibold text-[color:var(--ink)]">Theme</Label>
<div className="grid gap-3 sm:grid-cols-3">
{THEME_FAMILY_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setThemeFamily(option.value)}
className="rounded-[var(--r-md)] border p-4 text-left transition-colors"
style={{
borderColor:
themeFamily === option.value ? "var(--accent)" : "var(--border-ui)",
background:
themeFamily === option.value
? "var(--accent-subtle)"
: "var(--surface-muted)",
}}
>
<div className="text-sm font-semibold text-[color:var(--ink)]">{option.label}</div>
<div className="mt-1 text-xs text-[color:var(--ink-soft)]">
{option.description}
</div>
</button>
))}
</div>
<div className="flex flex-wrap gap-2">
<Button
variant={themeMode === "light" ? "primary" : "ghost"}
@@ -490,8 +493,7 @@ export function Settings() {
{/* Code & Content Section - Advanced */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
<Sparkles size={14} className="text-[color:var(--accent)]" />
<Label className="text-sm font-semibold text-[color:var(--ink)]">
Code &amp; Content
</Label>
@@ -528,10 +530,7 @@ export function Settings() {
</SelectTrigger>
<SelectContent>
<SelectItem value="full">
<span className="flex items-center gap-2">
<Zap size={14} />
Full
</span>
Full
</SelectItem>
<SelectItem value="reduced">Reduced</SelectItem>
<SelectItem value="none">None</SelectItem>
@@ -599,8 +598,7 @@ export function Settings() {
{/* Experimental Features - Advanced */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
<Sparkles size={14} className="text-[color:var(--gold)]" />
<Label className="text-sm font-semibold text-[color:var(--ink)]">
Experimental
</Label>
@@ -685,6 +683,32 @@ export function Settings() {
<>
<Separator className="my-2" />
{selectedOrg ? (
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-4">
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarImage
src={selectedOrgImage.trim() || undefined}
alt={selectedOrgDisplayName || selectedOrg.publisher.handle}
/>
<AvatarFallback className="text-lg">
{(selectedOrgDisplayName || selectedOrg.publisher.handle)
.charAt(0)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="text-base font-semibold text-[color:var(--ink)]">
{selectedOrgDisplayName || selectedOrg.publisher.handle}
</div>
<div className="text-sm text-[color:var(--ink-soft)]">
@{selectedOrg.publisher.handle}
</div>
</div>
</div>
</div>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="settings-manage-org">Manage org</Label>
<select
@@ -703,6 +727,39 @@ export function Settings() {
{selectedOrg && selectedOrg.role !== "publisher" ? (
<>
<div className="flex flex-col gap-2">
<Label htmlFor="settings-selected-org-display-name">Org display name</Label>
<Input
id="settings-selected-org-display-name"
value={selectedOrgDisplayName}
onChange={(event) => setSelectedOrgDisplayName(event.target.value)}
placeholder="OpenClaw"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="settings-selected-org-image">Profile image URL</Label>
<Input
id="settings-selected-org-image"
value={selectedOrgImage}
onChange={(event) => setSelectedOrgImage(event.target.value)}
placeholder="https://example.com/logo.png"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="settings-selected-org-bio">Org bio</Label>
<Textarea
id="settings-selected-org-bio"
rows={4}
value={selectedOrgBio}
onChange={(event) => setSelectedOrgBio(event.target.value)}
placeholder="Tell people what this organization publishes."
/>
</div>
<div className="flex items-center gap-3">
<Button type="button" onClick={() => void onSaveOrgProfile()}>
Save org profile
</Button>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="settings-add-member">Add member</Label>
<Input
+6 -5
View File
@@ -8,11 +8,11 @@ import {
MessageSquare,
Package,
Plug,
RefreshCw,
Search,
Shield,
Wrench,
X,
Zap,
} from "lucide-react";
import type { RefObject } from "react";
import { useMemo } from "react";
@@ -53,6 +53,7 @@ const SKILL_CAPABILITY_LABELS: Record<string, string> = {
"can-make-purchases": "Payments",
"can-sign-transactions": "Signs transactions",
"requires-oauth-token": "OAuth",
"requires-sensitive-credentials": "Sensitive credentials",
"posts-externally": "External posting",
};
@@ -63,7 +64,7 @@ const CATEGORY_ICONS: Record<string, React.ReactNode> = {
"dev-tools": <Wrench size={13} />,
data: <Database size={13} />,
security: <Shield size={13} />,
automation: <Zap size={13} />,
automation: <RefreshCw size={13} />,
other: <Package size={13} />,
};
@@ -260,10 +261,10 @@ function FilterChip({
type="button"
aria-pressed={active}
onClick={onClick}
className={`inline-flex items-center gap-1.5 rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 min-h-[36px] text-xs font-semibold transition-all duration-150 ${
className={`inline-flex min-h-[36px] items-center gap-1.5 rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 text-xs font-semibold transition-all duration-150 dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)] ${
active
? "border-[color:var(--accent)]/30 bg-[color:var(--accent)]/10 text-[color:var(--accent)]"
: "text-[color:var(--ink-soft)] hover:border-[color:var(--border-ui-hover)] hover:text-[color:var(--ink)] dark:text-[rgba(245,238,232,0.88)] dark:hover:text-[rgba(245,238,232,0.96)]"
? "border-[color:var(--accent)]/30 bg-[color:var(--accent)]/10 text-[color:var(--accent)] dark:border-[rgba(255,131,95,0.34)] dark:bg-[rgba(255,131,95,0.14)] dark:text-[#ffd5c9]"
: "text-[color:var(--ink-soft)] hover:border-[color:var(--border-ui-hover)] hover:text-[color:var(--ink)] dark:text-[rgba(245,238,232,0.88)] dark:hover:border-[rgba(255,255,255,0.2)] dark:hover:text-[rgba(245,238,232,0.96)]"
}`}
>
{active && !icon && <Check className="h-3 w-3" />}
@@ -27,6 +27,7 @@ const SKILL_CAPABILITY_LABELS: Record<string, string> = {
"can-make-purchases": "payments",
"can-sign-transactions": "signs transactions",
"requires-oauth-token": "oauth",
"requires-sensitive-credentials": "sensitive credentials",
"posts-externally": "external posting",
};
+1 -1
View File
@@ -8,7 +8,7 @@ import { getUserFacingConvexError } from "../../lib/convexError";
export async function uploadFile(uploadUrl: string, file: File) {
const path = file.webkitRelativePath || file.name;
const contentType =
normalizeTextContentType(path, file.type) ?? file.type ?? "application/octet-stream";
normalizeTextContentType(path, file.type) || file.type || "application/octet-stream";
const response = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": contentType },
+2097 -132
View File
File diff suppressed because it is too large Load Diff