Compare commits

..
Author SHA1 Message Date
Patrick ErichsenandClaude Opus 4.7 d6d4028660 refactor(security): swap ProxiedImg component for rehype plugin
Replaces the React <img> wrapper with a tiny rehype plugin that rewrites
image srcs in the HAST. Same behavior (external http(s) URLs routed
through /_vercel/image; local/relative/data: URIs pass through), less
surface area:

- One shared plugin wired into both MarkdownPreview and SkillDetailTabs
  via rehypePlugins instead of a components override at each call site
- Dropped ProxiedImg.tsx + its 7 unit tests; the two integration tests
  in MarkdownPreview.test.tsx still assert the proxy URL shape for both
  <img> and ![](url) syntax
- Stopped reading <img width="..."> for the proxy's w= param. Vercel
  requires w to match a value in vercel.json sizes, so arbitrary README
  widths (e.g. width="200") would have been rejected. Always w=1024 now;
  the HTML width attribute still drives layout

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:32:21 -07:00
Patrick ErichsenandClaude Opus 4.7 82ae30d940 fix(security): proxy README images via Vercel Image Optimization
Closes the XSS / IP-leak surface from rendering third-party README
images directly on clawhub.ai. Routes external http(s) <img> sources
through Vercel's /_vercel/image endpoint, which enforces a host
allow-list, rejects SVG by default, and re-encodes rasters to webp.

Docs: https://vercel.com/docs/image-optimization

- vercel.json: add `images` config — host allow-list (raw.githubusercontent,
  shields.io, etc., based on NuGet's published README allow-list),
  dangerouslyAllowSVG=false, formats=[webp], 1d minimum cache TTL.
- src/components/ProxiedImg.tsx: small wrapper that rewrites external
  http(s) src URLs to /_vercel/image?url=...&w=...&q=75. Local paths,
  relative paths, and data: URIs pass through unchanged.
- MarkdownPreview + SkillDetailTabs: pass ProxiedImg as the `img`
  component override to react-markdown — covers both raw HTML <img>
  and markdown ![](url) syntax.
- package.json: drop unused `next` dep (vestigial from staging merge,
  zero imports anywhere; doesn't affect next-themes).

Tests: 1028/1028 (was 1017, added 11 — ProxiedImg unit tests +
markdown integration tests covering proxied vs passthrough paths).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:12:40 -07:00
Patrick Erichsen b53813a5a7 Merge pull request #1792 from openclaw/fix/lint-cleanup-staging-fallout
chore(lint): clean up 70 oxlint errors from staging merge #1573
2026-04-22 21:39:34 -07:00
Patrick ErichsenandClaude Opus 4.7 87469792d5 fix(typecheck): clear remaining tsc errors on main
8 typecheck errors that have been on main alongside the lint debt:

- convex/apiSurface.typecheck.ts: drop two stale @ts-expect-error
  directives. The `increment` references they guarded no longer
  exist (functions renamed to *Internal); runtime internal-only
  enforcement is preserved by `internalMutation`.
- src/components/MarkdownPreview.tsx: cast createHighlighter result
  to AnyHighlighter, narrow loadHighlighter return via the local
  promise variable, type baseRehype + memoized rehypePlugins as
  PluggableList (drops `as const` readonly mismatch with
  ReactMarkdown's prop type).
- src/lib/theme.test.tsx: rename remaining "hub" usages to "claw"
  (theme families collapsed to one in PR #1573 — the last "hub"
  references in the harness button + applyTheme call would never
  compile under the current ThemeName type).
- src/lib/packageApi.test.ts: add `?.` on the nullable result.

Full suite: lint 0, tests 1017/1017, typecheck 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:33:17 -07:00
Patrick ErichsenandClaude Opus 4.7 9e7407cd84 test: update stale assertions left over from staging merge
Two pre-existing test failures on main, both caused by UI/data
changes in PR #1573 that the tests weren't updated for:

- theme.test.tsx: expected stored theme "hub" to round-trip, but
  the staging merge collapsed all families into a single "claw"
  theme — unknown families now fall back to "claw". Test now
  asserts the legacy fallback behavior it claims to test.
- skill-detail-page.test.tsx: gated on the platform license
  summary text, which was removed from SkillMetadataSidebar in
  4d1a08b. Drop the obsolete assertion; the report-button
  findByRole on the next line provides the same render-wait.

Full suite: 1017/1017 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:23:29 -07:00
Patrick ErichsenandClaude Opus 4.7 70fd9436cf chore(lint): clean up oxlint errors from staging-merge fallout (#1573)
Fixes 70 oxlint errors that landed in the 2026-04-18 staging merge and
have kept main red ever since. Three rule categories:

- typescript-eslint(no-unnecessary-type-conversion): drop redundant
  String/Number/Boolean wraps + 'as T' casts on values already typed.
- typescript-eslint(consistent-return): unify mixed return paths,
  mostly in useEffect callbacks (early-return vs cleanup-fn) and CLI
  command handlers.
- typescript-eslint(no-unnecessary-type-parameters): drop generics
  used only once in a signature; replace with concrete types.
- Plus a handful of no-unused-vars, no-shadow, and one
  no-redundant-type-constituents (JSX.Element -> ReactNode).

No runtime behavior changes. Full lint clean (0 errors); test suite
shows the same 2 pre-existing failures as main, no new regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:16:23 -07:00
Vincent Koc 5e7584e032 Merge pull request #1791 from openclaw/fix/markdown-html-passthrough 2026-04-22 20:59:27 -07:00
Patrick ErichsenandClaude Opus 4.7 ea0824878d fix(markdown): render raw HTML + GFM in MarkdownPreview, add shiki highlighting
Plugin/soul READMEs that use raw HTML (e.g. centered logos via
<h1 align="center">, <picture>, <br/>) were rendering as escaped
text because @create-markdown/preview escapes all HTML. Swap the
renderer for react-markdown + remark-gfm + rehype-raw +
rehype-sanitize (GitHub's stack), with rehype-shiki-from-highlighter
for fenced code block syntax highlighting.

Sanitize runs before shiki so user HTML is scrubbed, and shiki's
trusted styled output flows through untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:33:54 -07:00
Val Alexander da74b2a382 Update .gitignore 2026-04-22 14:22:53 -05:00
Val Alexander 4787be4eb1 Refresh Open Graph image (#1754)
* 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

* 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

* fix: refine clawhub og image

* fix: center og logo layout

* fix: emphasize clawhub branding in og image

* Refresh OG image branding

- Update Open Graph artwork and logo asset
- Adjust root metadata to use the new social preview image

* fix: refine clawhub og image

* fix: tighten og image layout

* fix: remove og logo panel

* fix: reduce og logo scale

* fix: align og image to new comp
2026-04-20 21:59:08 -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
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
80 changed files with 1530 additions and 653 deletions
+5 -2
View File
@@ -10,7 +10,9 @@ dist-ssr
*.local
.vercel
count.txt
.env
.env*
!.env.local.example
!.env.example
.nitro
.tanstack
.wrangler
@@ -27,4 +29,5 @@ test-results
convex/_generated/
skills-lock.json
*/skills/*
skills/*
skills/*
.codex/*
+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).
+1
View File
@@ -5,6 +5,7 @@
### Changed
- Search: add CJK tokenization support (Chinese/Japanese/Korean) with Intl.Segmenter plus fallback behavior to improve skill query matching (#1596) (thanks @pq-dong).
- 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
+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.
+31 -1
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "clawhub",
@@ -25,6 +26,7 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@resvg/resvg-wasm": "^2.6.2",
"@shikijs/rehype": "^4.0.2",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-devtools": "0.10.0",
"@tanstack/react-router": "1.168.1",
@@ -43,12 +45,13 @@
"ignore": "^7.0.5",
"lucide-react": "^0.577.0",
"monaco-editor": "^0.55.1",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"nitro": "3.0.260311-beta",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"semver": "^7.7.4",
"shiki": "^4.0.2",
@@ -56,6 +59,7 @@
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"unist-util-visit": "^5.1.0",
"vite-tsconfig-paths": "^6.1.1",
"yaml": "^2.8.3",
"zod": "^4.3.6",
@@ -591,6 +595,8 @@
"@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="],
"@shikijs/rehype": ["@shikijs/rehype@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@types/hast": "^3.0.4", "hast-util-to-string": "^3.0.1", "shiki": "4.0.2", "unified": "^11.0.5", "unist-util-visit": "^5.1.0" } }, "sha512-cmPlKLD8JeojasNFoY64162ScpEdEdQUMuVodPCrv1nx1z3bjmGwoKWDruQWa/ejSznImlaeB0Ty6Q3zPaVQAA=="],
"@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="],
"@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
@@ -961,12 +967,26 @@
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
"hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
"hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="],
"hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
"hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
@@ -1283,6 +1303,10 @@
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
"rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
@@ -1441,6 +1465,8 @@
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@8.0.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ=="],
@@ -1453,6 +1479,8 @@
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
@@ -1595,6 +1623,8 @@
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
+4 -7
View File
@@ -1,10 +1,7 @@
import { api, internal } from "./_generated/api";
import { internal } from "./_generated/api";
// Asserts that the internal-only download counters remain internal-only.
// Public exposure is prevented at runtime by `internalMutation`; this file
// just pins the public references that *should* exist.
void internal.downloads.recordDownloadInternal;
void internal.soulDownloads.incrementInternal;
// @ts-expect-error download counters must not be publicly callable
void api.downloads.increment;
// @ts-expect-error soul download counters must not be publicly callable
void api.soulDownloads.increment;
+1 -1
View File
@@ -229,7 +229,7 @@ export async function applyCommentScamResultInternalHandler(
ok: true,
shouldBan,
banned: !banResult.alreadyBanned,
alreadyBanned: Boolean(banResult.alreadyBanned),
alreadyBanned: banResult.alreadyBanned,
protectedRole: false,
wouldBan: false,
};
+1 -1
View File
@@ -509,7 +509,7 @@ describe("comments mutations", () => {
if (id === "skills:1") {
return { _id: "skills:1", softDeletedAt: undefined, moderationStatus: "active" };
}
if (String(id).startsWith("comments:reported-")) return reportedComment;
if (id.startsWith("comments:reported-")) return reportedComment;
if (id === "skills:active") {
return { _id: "skills:active", softDeletedAt: undefined, moderationStatus: "active" };
}
+1 -1
View File
@@ -704,7 +704,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
summary: mod.summary,
engineVersion: mod.engineVersion,
updatedAt: mod.updatedAt,
evidence: sanitizeEvidence(mod.evidence, Boolean(isOwner || isStaff)),
evidence: sanitizeEvidence(mod.evidence, isOwner || isStaff),
legacyReason: isOwner || isStaff ? mod.reason : null,
}
: null,
+2 -2
View File
@@ -1,9 +1,9 @@
import type { Scheduler } from "convex/server";
export function scheduleNextBatchIfNeeded<TArgs extends { cursor?: string }>(
export function scheduleNextBatchIfNeeded(
scheduler: Scheduler,
fn: unknown,
args: TArgs,
args: { cursor?: string } & Record<string, unknown>,
isDone: boolean,
continueCursor: string | null,
) {
+4 -4
View File
@@ -279,8 +279,8 @@ function decodeJwt(jwt: string) {
const parts = jwt.trim().split(".");
if (parts.length !== 3) throw new Error("Invalid GitHub OIDC token format");
const [encodedHeader, encodedPayload, encodedSignature] = parts;
const header = parseJsonSegment<JwtHeader>(encodedHeader, "header");
const payload = parseJsonSegment<JwtPayload>(encodedPayload, "payload");
const header = parseJsonSegment(encodedHeader, "header") as JwtHeader;
const payload = parseJsonSegment(encodedPayload, "payload") as JwtPayload;
return {
header,
payload,
@@ -289,9 +289,9 @@ function decodeJwt(jwt: string) {
};
}
function parseJsonSegment<T>(segment: string, label: string) {
function parseJsonSegment(segment: string, label: string): unknown {
try {
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(segment))) as T;
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(segment)));
} catch {
throw new Error(`Invalid GitHub OIDC ${label}`);
}
+6 -6
View File
@@ -144,13 +144,13 @@ export async function deletePackageSearchDigests(
}
}
function hasDigestChanged<
TExisting extends Record<string, unknown>,
TFields extends Record<string, unknown>,
>(existing: TExisting, fields: TFields): boolean {
function hasDigestChanged(
existing: Record<string, unknown>,
fields: Record<string, unknown>,
): boolean {
for (const key of Object.keys(fields)) {
const oldValue = (existing as Record<string, unknown>)[key];
const newValue = (fields as Record<string, unknown>)[key];
const oldValue = existing[key];
const newValue = fields[key];
if (oldValue === newValue) continue;
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) return true;
}
+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));
+4 -4
View File
@@ -372,7 +372,7 @@ function parseDependencyDeclarations(input: unknown): Array<{
version?: string;
url?: string;
repository?: string;
} = { name: String(obj.name).trim(), type: depType };
} = { name: obj.name.trim(), type: depType };
if (typeof obj.version === "string") decl.version = obj.version.trim();
if (typeof obj.url === "string") decl.url = obj.url.trim();
if (typeof obj.repository === "string") decl.repository = obj.repository.trim();
@@ -432,7 +432,7 @@ function parseFrontmatterLevelDeclarations(
// Parse primaryEnv from top-level frontmatter
if (typeof frontmatter.primaryEnv === "string") {
metadata.primaryEnv = String(frontmatter.primaryEnv).trim();
metadata.primaryEnv = frontmatter.primaryEnv.trim();
}
const envVars = parseEnvVarDeclarations(frontmatter.env);
@@ -441,13 +441,13 @@ function parseFrontmatterLevelDeclarations(
const dependencies = parseDependencyDeclarations(frontmatter.dependencies);
if (dependencies.length > 0) metadata.dependencies = dependencies;
if (typeof frontmatter.author === "string") metadata.author = String(frontmatter.author).trim();
if (typeof frontmatter.author === "string") metadata.author = frontmatter.author.trim();
const links = parseSkillLinks(frontmatter.links);
if (links) metadata.links = links;
if (typeof frontmatter.homepage === "string") {
metadata.homepage = String(frontmatter.homepage).trim();
metadata.homepage = frontmatter.homepage.trim();
}
return Object.keys(metadata).length > 0
+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
+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(),
+1 -1
View File
@@ -191,7 +191,7 @@ describe("skills.getPendingScanSkillsInternal", () => {
const versionId = skill.latestVersionId as string;
return [
versionId,
{ _id: versionId, sha256hash: `${String(versionId).slice(-8)}${"f".repeat(56)}` },
{ _id: versionId, sha256hash: `${versionId.slice(-8)}${"f".repeat(56)}` },
];
}),
);
+4 -4
View File
@@ -653,7 +653,7 @@ describe("skills anti-spam guards", () => {
const runAfter = vi.fn();
const db = {
get: vi.fn(async (tableOrId: string, maybeId?: string) => {
const key = String(maybeId ?? tableOrId);
const key = maybeId ?? tableOrId;
if (storedSkills.has(key)) return storedSkills.get(key);
if (key === "users:owner") {
return {
@@ -759,7 +759,7 @@ describe("skills anti-spam guards", () => {
patch,
insert,
normalizeId: vi.fn((tableName: string, id: string) =>
String(id).startsWith(`${tableName}:`) ? id : null,
id.startsWith(`${tableName}:`) ? id : null,
),
};
@@ -840,7 +840,7 @@ describe("skills anti-spam guards", () => {
});
const db = {
get: vi.fn(async (tableOrId: string, maybeId?: string) => {
const key = String(maybeId ?? tableOrId);
const key = maybeId ?? tableOrId;
if (storedSkills.has(key)) return storedSkills.get(key);
if (key === "users:owner") {
return {
@@ -948,7 +948,7 @@ describe("skills anti-spam guards", () => {
patch,
insert,
normalizeId: vi.fn((tableName: string, id: string) =>
String(id).startsWith(`${tableName}:`) ? id : null,
id.startsWith(`${tableName}:`) ? id : null,
),
};
+2 -5
View File
@@ -4794,8 +4794,6 @@ export const escalateByVtInternal = internalMutation({
slug: skill.slug,
});
}
return { ok: true, skillId: version.skillId, versionId: version._id };
},
});
@@ -6264,9 +6262,8 @@ export const insertVersion = internalMutation({
// Trusted publishers (and moderators/admins) bypass auto-hide for pending scans.
// Keep moderationReason as pending.scan so the VT poller keeps working.
const isTrustedPublisher = Boolean(
user.trustedPublisher || user.role === "admin" || user.role === "moderator",
);
const isTrustedPublisher =
user.trustedPublisher || user.role === "admin" || user.role === "moderator";
const staticSnapshot = buildModerationSnapshot({
staticScan: args.staticScan,
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

+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);
});
+4 -1
View File
@@ -52,6 +52,7 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@resvg/resvg-wasm": "^2.6.2",
"@shikijs/rehype": "^4.0.2",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-devtools": "0.10.0",
"@tanstack/react-router": "1.168.1",
@@ -70,12 +71,13 @@
"ignore": "^7.0.5",
"lucide-react": "^0.577.0",
"monaco-editor": "^0.55.1",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"nitro": "3.0.260311-beta",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"semver": "^7.7.4",
"shiki": "^4.0.2",
@@ -83,6 +85,7 @@
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"unist-util-visit": "^5.1.0",
"vite-tsconfig-paths": "^6.1.1",
"yaml": "^2.8.3",
"zod": "^4.3.6"
+1 -1
View File
@@ -178,7 +178,7 @@ function addConfigRoots(
const extraDirs = config.skills?.load?.extraDirs ?? [];
for (const dir of extraDirs) {
const resolved = resolveUserPath(String(dir));
const resolved = resolveUserPath(dir);
if (!resolved) continue;
const label = `${prefix}Extra: ${basename(resolved) || resolved}`;
pushRoot(roots, labels, resolved, label);
+1 -1
View File
@@ -22,7 +22,7 @@ export async function cmdLoginFlow(
fail("Token required (use --token or remove --no-browser)");
}
const label = String(options.label ?? "CLI token").trim() || "CLI token";
const label = (options.label ?? "CLI token").trim() || "CLI token";
const receiver = await startLoopbackAuthServer();
const discovery = await discoverRegistryFromSite(opts.site).catch(() => null);
const authBase = discovery?.authBase?.trim() || opts.site;
+2 -2
View File
@@ -54,7 +54,7 @@ export async function cmdDeleteSkill(
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(formatPrompt(labels, slug));
if (!ok) return;
if (!ok) return undefined;
}
const token = await requireAuthToken();
@@ -88,7 +88,7 @@ export async function cmdUndeleteSkill(
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(formatPrompt(labels, slug));
if (!ok) return;
if (!ok) return undefined;
}
const token = await requireAuthToken();
@@ -149,7 +149,7 @@ describe("github publish source helpers", () => {
const workdir = await makeTmpDir();
const restoreFetch =
input.includes("/tree/") || input.includes("/blob/")
? mockGitHubCommitLookup([String((expected as { ref?: string }).ref ?? "")])
? mockGitHubCommitLookup([(expected as { ref?: string }).ref ?? ""])
: null;
try {
await expect(resolveSourceInput(input, { workdir })).resolves.toEqual(expected);
@@ -33,13 +33,13 @@ export async function cmdBanUser(
{ id: options.id, fuzzy: options.fuzzy },
allowPrompt,
);
if (!resolved) return;
if (!resolved) return undefined;
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(
`Ban ${resolved.label}? (requires moderator/admin; deletes owned skills)`,
);
if (!ok) return;
if (!ok) return undefined;
}
const spinner = createSpinner(`Banning ${resolved.label}`);
@@ -90,11 +90,11 @@ export async function cmdSetRole(
{ id: options.id, fuzzy: options.fuzzy },
allowPrompt,
);
if (!resolved) return;
if (!resolved) return undefined;
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(`Set role for ${resolved.label} to ${role}? (admin only)`);
if (!ok) return;
if (!ok) return undefined;
}
const spinner = createSpinner(`Setting role for ${resolved.label}`);
@@ -218,7 +218,7 @@ function formatUserList(users: UserSearchItem[]) {
function normalizeRole(value: string) {
const role = value.trim().toLowerCase();
if (role === "user" || role === "moderator" || role === "admin") return role;
fail("Role must be user|moderator|admin");
return fail("Role must be user|moderator|admin");
}
function formatDeletedSkills(count: number) {
@@ -44,7 +44,7 @@ export async function cmdRenameSkill(
inputAllowed,
`Rename ${slug} to ${newSlug}? Old slug will redirect.`,
);
if (!confirmed) return;
if (!confirmed) return undefined;
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
@@ -86,7 +86,7 @@ export async function cmdMergeSkill(
inputAllowed,
`Merge ${sourceSlug} into ${targetSlug}? Source slug will redirect and stop listing publicly.`,
);
if (!confirmed) return;
if (!confirmed) return undefined;
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
@@ -77,7 +77,7 @@ function getPublishPayload() {
function getUploadedFileNames() {
const form = getPublishForm();
return (form.getAll("files") as Array<Blob & { name?: string }>)
.map((file) => String(file.name ?? ""))
.map((file) => file.name ?? "")
.sort();
}
@@ -782,7 +782,7 @@ function detectPackageFamily(
if (explicit) return explicit;
if (fileSet.has("openclaw.plugin.json")) return "code-plugin";
if (fileSet.has("openclaw.bundle.json")) return "bundle-plugin";
fail("Could not detect package family. Use --family.");
return fail("Could not detect package family. Use --family.");
}
function parseTags(value: string) {
@@ -79,7 +79,7 @@ describe("cmdPublish", () => {
expect(payload.acceptLicenseTerms).toBe(true);
expect(payload.tags).toEqual(["latest"]);
const files = publishForm.getAll("files") as Array<Blob & { name?: string }>;
expect(files.map((file) => String(file.name ?? "")).sort()).toEqual(["SKILL.md", "notes.md"]);
expect(files.map((file) => file.name ?? "").sort()).toEqual(["SKILL.md", "notes.md"]);
} finally {
await rm(workdir, { recursive: true, force: true });
}
+1 -1
View File
@@ -478,7 +478,7 @@ function resolveExploreSort(raw?: string): { sort: ExploreSort; apiSort: ApiExpl
if (normalized === "trending") {
return { sort: "trending", apiSort: "trending" };
}
fail(
return fail(
`Invalid sort "${raw}". Use newest, downloads, rating, installs, installsAllTime, or trending.`,
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ export async function cmdStarSkill(
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(`Star ${slug}?`);
if (!ok) return;
if (!ok) return undefined;
}
const token = await requireAuthToken();
@@ -282,7 +282,7 @@ export async function selectToUpload(
required: false,
});
if (isCancel(picked)) fail("Canceled");
const selected = picked.map((key) => valueByKey.get(String(key))).filter(Boolean) as Candidate[];
const selected = picked.map((key) => valueByKey.get(key)).filter(Boolean) as Candidate[];
return selected;
}
@@ -75,7 +75,7 @@ export async function cmdTransferRequest(
inputAllowed,
`Transfer ${slug} to @${toHandle}? Recipient must accept.`,
);
if (!confirmed) return;
if (!confirmed) return undefined;
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
@@ -160,7 +160,7 @@ async function runTransferDecision(
inputAllowed,
`${spec.verb} transfer of ${slug}?`,
);
if (!confirmed) return;
if (!confirmed) return undefined;
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
+1 -1
View File
@@ -18,7 +18,7 @@ export async function cmdUnstarSkill(
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(`Unstar ${slug}?`);
if (!ok) return;
if (!ok) return undefined;
}
const token = await requireAuthToken();
+2 -2
View File
@@ -40,7 +40,7 @@ export async function promptHidden(prompt: string) {
export async function promptConfirm(prompt: string) {
const answer = await confirm({ message: prompt });
if (isCancel(answer)) return false;
return Boolean(answer);
return answer;
}
export function openInBrowser(url: string) {
@@ -70,7 +70,7 @@ export function openInBrowser(url: string) {
}
export function isInteractive() {
return Boolean(process.stdout.isTTY && stdin.isTTY);
return process.stdout.isTTY && stdin.isTTY;
}
export function createSpinner(text: string) {
+2 -2
View File
@@ -381,7 +381,7 @@ function getRetryDelayMs(attemptError: unknown, random: () => number): number {
cause?: unknown;
error?: unknown;
};
const attemptNumber = Math.max(1, Number(failed.attemptNumber ?? 1));
const attemptNumber = Math.max(1, failed.attemptNumber ?? 1);
const rootError = failed.cause ?? failed.error ?? attemptError;
if (rootError instanceof HttpStatusError && rootError.rateLimit.retryAfterSeconds !== undefined) {
return rootError.rateLimit.retryAfterSeconds * 1000 + jitterMs(RETRY_AFTER_JITTER_MS, random);
@@ -568,7 +568,7 @@ async function fetchJsonFormViaCurl(
await deps.writeFileImpl(filePath, bytes);
formArgs.push("-F", `${key}=@${filePath};filename=${filename}`);
} else {
formArgs.push("-F", `${key}=${String(value)}`);
formArgs.push("-F", `${key}=${value}`);
}
}
+3 -3
View File
@@ -133,9 +133,9 @@ export async function readSkillOrigin(skillFolder: string): Promise<SkillOrigin
}
return {
version: 1,
registry: String(parsed.registry),
slug: String(parsed.slug),
installedVersion: String(parsed.installedVersion),
registry: parsed.registry,
slug: parsed.slug,
installedVersion: parsed.installedVersion,
installedAt: parsed.installedAt,
};
} catch {
+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.

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

+102
View File
@@ -0,0 +1,102 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<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="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>
<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="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>
<rect width="1200" height="630" fill="#030305"/>
<rect width="1200" height="630" fill="url(#bgGlowRight)"/>
<rect width="1200" height="630" fill="url(#bgGlowBottom)"/>
<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>
<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"/>
<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"/>
<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)"/>
<image href="clawd-logo.png" x="124" y="158" width="270" height="270" preserveAspectRatio="xMidYMid meet"/>
<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>
<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>

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",
+130 -94
View File
@@ -9,149 +9,185 @@ const siteModeMock = vi.fn(() => "souls");
const navigateMock = vi.fn();
vi.mock("@tanstack/react-router", () => ({
Link: (props: {
children: ReactNode;
className?: string;
hash?: string;
to?: string;
}) => (
<a href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`} className={props.className}>
{props.children}
</a>
),
useLocation: () => ({ pathname: "/" }),
useNavigate: () => navigateMock,
Link: (props: {
children: ReactNode;
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();
setModeMock.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.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();
expect(
screen.getByRole("button", { name: /Cycle theme mode/i }),
).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);
expect(screen.getAllByText("Users")).toHaveLength(1);
expect(
screen.getByPlaceholderText("Search skills, plugins, users"),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
expect(setModeMock).toHaveBeenCalledWith("light");
fireEvent.click(screen.getByRole("button", { name: /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("Users")).toHaveLength(2);
});
expect(screen.getAllByText("Home")).toHaveLength(1);
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Users")).toHaveLength(2);
});
it("routes soul-mode header searches to the souls browse page", () => {
siteModeMock.mockReturnValue("souls");
navigateMock.mockReset();
it("shows Home above Skills in the mobile menu", () => {
siteModeMock.mockReturnValue("skills");
render(<Header />);
render(<Header />);
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
target: { value: "angler" },
});
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
expect(navigateMock).toHaveBeenCalledWith({
to: "/souls",
search: {
q: "angler",
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
},
});
});
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,
},
});
});
});
-7
View File
@@ -412,13 +412,6 @@ describe("SkillDetailPage", () => {
render(<SkillDetailPage slug="weather" />);
expect(
(
await screen.findAllByText(
/free to use, modify, and redistribute\. no attribution required\./i,
)
).length,
).toBeGreaterThan(0);
expect(
screen.queryByText(/Reports require a reason\. Abuse may result in a ban\./i),
).toBeNull();
+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} />,
};
+20 -4
View File
@@ -129,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">
@@ -202,10 +219,9 @@ export default function Header() {
search={{ q: undefined, highlighted: undefined, search: undefined }}
className="brand"
>
{/* TODO: Re-introduce logo once new asset is ready */}
{/* <span className="brand-mark">
<span className="brand-mark">
<img src="/clawd-logo.png" alt="" aria-hidden="true" className="brand-mark-image" />
</span> */}
</span>
<span className="brand-name brand-name-responsive">{siteName}</span>
</Link>
+2
View File
@@ -23,6 +23,8 @@ export function InstallSwitcher({ exampleSlug = "sonoscli" }: InstallSwitcherPro
return `pnpm dlx clawhub@latest install ${exampleSlug}`;
case "bun":
return `bunx clawhub@latest install ${exampleSlug}`;
default:
return `npx clawhub@latest install ${exampleSlug}`;
}
}, [exampleSlug, pm]);
+195
View File
@@ -0,0 +1,195 @@
/* @vitest-environment jsdom */
import { render, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { MarkdownPreview } from "./MarkdownPreview";
function renderMarkdown(source: string) {
// Disable Shiki highlighting to keep the tree synchronous for assertions.
const { container } = render(<MarkdownPreview highlight={false}>{source}</MarkdownPreview>);
return container;
}
describe("MarkdownPreview — raw HTML passthrough", () => {
it("renders an <h1 align=\"center\"> block as a real <h1>", () => {
const container = renderMarkdown(`<h1 align="center">Hello logo</h1>`);
const h1 = container.querySelector("h1");
expect(h1).not.toBeNull();
expect(h1?.textContent).toBe("Hello logo");
});
it("renders a <div align=\"center\"> block as a real <div>", () => {
const container = renderMarkdown(`<div align="center">centered</div>`);
const div = container.querySelector("div[align=\"center\"]");
expect(div).not.toBeNull();
expect(div?.textContent).toBe("centered");
});
it("renders <picture> with <source> + <img> fallback", () => {
const container = renderMarkdown(
`<picture><source media="(prefers-color-scheme: dark)" srcset="dark.png"/><img alt="Logo" src="light.png"/></picture>`,
);
expect(container.querySelector("picture")).not.toBeNull();
expect(container.querySelector("picture source")).not.toBeNull();
const img = container.querySelector("picture img");
expect(img).not.toBeNull();
expect(img?.getAttribute("alt")).toBe("Logo");
expect(img?.getAttribute("src")).toBe("light.png");
});
it("renders standalone <img> tags with src and alt", () => {
const container = renderMarkdown(`<img src="screenshot.png" alt="Demo screenshot"/>`);
const img = container.querySelector("img");
expect(img).not.toBeNull();
// Relative paths render as-is — only external http(s) URLs get proxied.
expect(img?.getAttribute("src")).toBe("screenshot.png");
expect(img?.getAttribute("alt")).toBe("Demo screenshot");
});
it("routes external https <img> URLs through /_vercel/image", () => {
const container = renderMarkdown(
`<img src="https://raw.githubusercontent.com/foo/bar/main/logo.png" alt="logo"/>`,
);
const img = container.querySelector("img");
expect(img?.getAttribute("src")).toBe(
"/_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Ffoo%2Fbar%2Fmain%2Flogo.png&w=1024&q=75",
);
});
it("routes external markdown ![](url) images through /_vercel/image", () => {
const container = renderMarkdown(`![logo](https://img.shields.io/badge/x-y-blue.svg)`);
const img = container.querySelector("img");
expect(img?.getAttribute("src")).toBe(
"/_vercel/image?url=https%3A%2F%2Fimg.shields.io%2Fbadge%2Fx-y-blue.svg&w=1024&q=75",
);
});
it("renders <br/> as a real line break", () => {
const container = renderMarkdown(`line one<br/>line two`);
expect(container.querySelector("br")).not.toBeNull();
});
it("renders the Opik README banner (centered h1 + picture + img)", () => {
const opikBanner = `<h1 align="center">
<a href="https://www.comet.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="dark.svg"/>
<img alt="Comet Opik logo" src="light.svg" width="200"/>
</picture>
</a>
<br/>OpenClaw Opik Observability Plugin
</h1>`;
const container = renderMarkdown(opikBanner);
expect(container.querySelector("h1")).not.toBeNull();
expect(container.querySelector("picture")).not.toBeNull();
const img = container.querySelector("img");
expect(img?.getAttribute("alt")).toBe("Comet Opik logo");
// And the escaped tag must NOT be present as literal text anywhere.
expect(container.textContent ?? "").not.toContain("<picture>");
});
});
describe("MarkdownPreview — standard markdown still renders", () => {
it("renders ATX headings", () => {
const container = renderMarkdown(`## Why This Plugin`);
const h2 = container.querySelector("h2");
expect(h2?.textContent).toBe("Why This Plugin");
});
it("renders markdown links", () => {
const container = renderMarkdown(`[Opik](https://example.com/opik)`);
const a = container.querySelector("a");
expect(a?.getAttribute("href")).toBe("https://example.com/opik");
expect(a?.textContent).toBe("Opik");
});
it("renders inline code", () => {
const container = renderMarkdown("Use `@opik/opik-openclaw` now.");
const code = container.querySelector("code");
expect(code?.textContent).toBe("@opik/opik-openclaw");
});
it("renders unordered lists", () => {
const container = renderMarkdown(`- one\n- two\n- three`);
const items = container.querySelectorAll("li");
expect(items.length).toBe(3);
expect(items[0].textContent).toBe("one");
});
it("renders GFM tables", () => {
const container = renderMarkdown(
[
"| Key | Value |",
"| --- | ----- |",
"| a | 1 |",
"| b | 2 |",
].join("\n"),
);
expect(container.querySelector("table")).not.toBeNull();
expect(container.querySelectorAll("tbody tr").length).toBe(2);
});
it("renders fenced code blocks as <pre><code>", () => {
const container = renderMarkdown("```ts\nconst x = 1;\n```");
const code = container.querySelector("pre code");
expect(code).not.toBeNull();
expect(code?.textContent).toContain("const x = 1;");
});
});
describe("MarkdownPreview — syntax highlighting", () => {
it("shiki-highlights fenced code blocks (produces colored <span> tokens)", async () => {
const { container } = render(
<MarkdownPreview>{"```ts\nconst x: number = 1;\n```"}</MarkdownPreview>,
);
await waitFor(
() => {
const pre = container.querySelector("pre");
// Shiki wraps the output in <pre class="shiki ..."> and tokens are
// <span style="color:#...">.
expect(pre?.className ?? "").toMatch(/shiki/);
const coloredSpans = container.querySelectorAll("pre span[style*='color']");
expect(coloredSpans.length).toBeGreaterThan(0);
},
{ timeout: 8000 },
);
// Raw code text must still be present after highlighting
expect(container.querySelector("pre")?.textContent).toContain("const x");
});
it("leaves the highlight prop honored — highlight={false} renders plain <pre><code>", () => {
const { container } = render(
<MarkdownPreview highlight={false}>{"```ts\nconst x = 1;\n```"}</MarkdownPreview>,
);
const pre = container.querySelector("pre");
// No shiki class, no colored spans
expect(pre?.className ?? "").not.toMatch(/shiki/);
expect(container.querySelectorAll("pre span[style*='color']").length).toBe(0);
expect(pre?.textContent).toContain("const x = 1;");
});
});
describe("MarkdownPreview — sanitization of malicious HTML", () => {
it("strips <script> tags", () => {
const container = renderMarkdown(`hello<script>window.__pwn = 1;</script>world`);
expect(container.querySelector("script")).toBeNull();
expect(container.textContent ?? "").not.toContain("window.__pwn");
});
it("strips onerror handlers on <img>", () => {
const container = renderMarkdown(`<img src="x" onerror="window.__pwn = 1" alt="x"/>`);
const img = container.querySelector("img");
// The img itself can render; the handler must be gone.
expect(img?.getAttribute("onerror")).toBeNull();
});
it("strips javascript: hrefs on anchors", () => {
const container = renderMarkdown(`<a href="javascript:alert(1)">click</a>`);
const a = container.querySelector("a");
// Either the href is removed entirely or rewritten — it must not start with javascript:
const href = a?.getAttribute("href") ?? "";
expect(href.toLowerCase().startsWith("javascript:")).toBe(false);
});
});
+92 -69
View File
@@ -1,94 +1,117 @@
import { parse } from "@create-markdown/core";
import { blocksToHTML, renderAsync, shikiPlugin } from "@create-markdown/preview";
import { useEffect, useRef, useState } from "react";
import rehypeShikiFromHighlighter from "@shikijs/rehype/core";
import { useEffect, useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import remarkGfm from "remark-gfm";
import type { HighlighterGeneric } from "shiki";
import type { PluggableList } from "unified";
import { rehypeProxyImages } from "../lib/rehypeProxyImages";
import { cn } from "../lib/utils";
interface MarkdownPreviewProps {
children: string;
className?: string;
/** Enable Shiki syntax highlighting for code blocks (async). Default: true */
/** Enable Shiki syntax highlighting for fenced code blocks. Default: true. */
highlight?: boolean;
}
/**
* Auto-link bare URLs in HTML that aren't already inside anchor tags or attributes.
* Matches http/https URLs in text nodes only (not inside tags).
*/
function autolinkURLs(html: string): string {
// Split HTML into tags and text segments, then only linkify text segments
return html.replace(
/(<[^>]*>)|((https?:\/\/)[^\s<>"')\]]+)/gi,
(match, tag: string | undefined, url: string | undefined) => {
// If it's an HTML tag, leave it alone
if (tag) return tag;
// If it's a bare URL in text content, wrap it
if (url) {
// Trim trailing punctuation that's likely not part of the URL
const trailingPunct = /[.,;:!?)]+$/.exec(url);
const cleanUrl = trailingPunct ? url.slice(0, -trailingPunct[0].length) : url;
const suffix = trailingPunct ? trailingPunct[0] : "";
return `<a href="${cleanUrl}" target="_blank" rel="noopener noreferrer">${cleanUrl}</a>${suffix}`;
}
return match;
},
);
const schema = {
...defaultSchema,
tagNames: [...(defaultSchema.tagNames ?? []), "picture", "source"],
attributes: {
...defaultSchema.attributes,
"*": [...(defaultSchema.attributes?.["*"] ?? []), "align"],
img: [...(defaultSchema.attributes?.img ?? []), "width", "height"],
source: ["media", "srcSet", "srcset", "type"],
picture: [],
},
};
// Order matters: rehype-sanitize runs BEFORE rehype-shiki so sanitize only
// sees user-authored HTML; shiki's trusted styled output flows through after.
// rehypeProxyImages rewrites after sanitize so we rewrite only already-safe
// <img src="..."> nodes (sanitize strips event handlers, javascript: URLs).
const baseRehype: PluggableList = [
rehypeRaw,
[rehypeSanitize, schema],
rehypeProxyImages,
];
const SHIKI_THEME = "github-dark";
const SHIKI_LANGS = [
"bash",
"sh",
"shell",
"ts",
"tsx",
"js",
"jsx",
"json",
"yaml",
"md",
"python",
"nix",
"http",
"html",
"css",
"toml",
"rust",
"go",
"dockerfile",
"diff",
];
type AnyHighlighter = HighlighterGeneric<string, string>;
let highlighterPromise: Promise<AnyHighlighter> | null = null;
function loadHighlighter(): Promise<AnyHighlighter> {
if (!highlighterPromise) {
highlighterPromise = import("shiki").then(
({ createHighlighter }) =>
createHighlighter({
themes: [SHIKI_THEME],
langs: SHIKI_LANGS,
}) as Promise<AnyHighlighter>,
);
}
return highlighterPromise;
}
/**
* Rich markdown preview using @create-markdown/preview.
* Renders markdown HTML with optional Shiki syntax highlighting.
* Falls back to synchronous (unhighlighted) rendering while Shiki loads.
*/
export function MarkdownPreview({ children, className, highlight = true }: MarkdownPreviewProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Initial sync render (no highlighting) for instant display
const [html, setHtml] = useState(() => {
try {
const blocks = parse(children);
return autolinkURLs(blocksToHTML(blocks));
} catch {
return "";
}
});
const [highlighter, setHighlighter] = useState<AnyHighlighter | null>(null);
useEffect(() => {
let cancelled = false;
// Re-parse synchronously on content change
try {
const blocks = parse(children);
const syncHtml = autolinkURLs(blocksToHTML(blocks));
setHtml(syncHtml);
if (!highlight) return;
// Async render with Shiki syntax highlighting
void renderAsync(blocks, {
plugins: [shikiPlugin({ theme: "github-dark" })],
})
.then((highlighted) => {
if (!cancelled) {
setHtml(autolinkURLs(highlighted));
}
if (highlight) {
loadHighlighter()
.then((h) => {
if (!cancelled) setHighlighter(h);
})
.catch(() => {
// Shiki failed to load — keep the sync render
// Shiki failed to initialize — keep plain rendering.
});
} catch {
// Parse failed — clear
setHtml("");
}
return () => {
cancelled = true;
};
}, [children, highlight]);
}, [highlight]);
const rehypePlugins = useMemo<PluggableList>(() => {
if (highlight && highlighter) {
return [
...baseRehype,
[rehypeShikiFromHighlighter, highlighter, { theme: SHIKI_THEME }],
];
}
return baseRehype;
}, [highlight, highlighter]);
return (
<div
ref={containerRef}
className={cn("markdown", className)}
dangerouslySetInnerHTML={{ __html: html }}
/>
<div className={cn("markdown", className)}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={rehypePlugins}>
{children}
</ReactMarkdown>
</div>
);
}
+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">
+17 -20
View File
@@ -236,28 +236,25 @@ export function SkillDetailPage({
}, [navigate, ownerParam, slug, wantsCanonicalRedirect]);
useEffect(() => {
if (!latestVersion) return;
if (loadedReadmeVersionId === latestVersion._id && (readme !== null || readmeError !== null)) {
return;
}
setReadme(null);
setReadmeError(null);
setLoadedReadmeVersionId(latestVersion._id);
let cancelled = false;
if (latestVersion && !(loadedReadmeVersionId === latestVersion._id && (readme !== null || readmeError !== null))) {
setReadme(null);
setReadmeError(null);
setLoadedReadmeVersionId(latestVersion._id);
void getReadme({ versionId: latestVersion._id })
.then((data) => {
if (cancelled) return;
setReadme(data.text);
setLoadedReadmeVersionId(latestVersion._id);
})
.catch((error) => {
if (cancelled) return;
setReadmeError(error instanceof Error ? error.message : "Failed to load README");
setReadme(null);
setLoadedReadmeVersionId(latestVersion._id);
});
void getReadme({ versionId: latestVersion._id })
.then((data) => {
if (cancelled) return;
setReadme(data.text);
setLoadedReadmeVersionId(latestVersion._id);
})
.catch((error) => {
if (cancelled) return;
setReadmeError(error instanceof Error ? error.message : "Failed to load README");
setReadme(null);
setLoadedReadmeVersionId(latestVersion._id);
});
}
return () => {
cancelled = true;
+6 -1
View File
@@ -2,8 +2,11 @@ import { lazy, Suspense } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { rehypeProxyImages } from "../lib/rehypeProxyImages";
import { SkillVersionsPanel } from "./SkillVersionsPanel";
const REHYPE_PLUGINS = [rehypeProxyImages];
const SkillDiffCard = lazy(() =>
import("./SkillDiffCard").then((module) => ({ default: module.SkillDiffCard })),
);
@@ -96,7 +99,9 @@ export function SkillDetailTabs({
<div className="tab-body">
{readmeContent ? (
<div className="markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readmeContent}</ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={REHYPE_PLUGINS}>
{readmeContent}
</ReactMarkdown>
</div>
) : readmeError ? (
<div className="empty-state px-[var(--space-4)] py-[var(--space-6)]">
+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);
});
});
+3 -4
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";
@@ -232,7 +232,7 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
}, [getFileText, leftVersionId, rightVersionId, selectedItem]);
useEffect(() => {
if (!monaco || typeof document === "undefined") return;
if (!monaco || typeof document === "undefined") return () => {};
const syncTheme = () => applyMonacoTheme(monaco);
const observer = new MutationObserver(syncTheme);
observer.observe(document.documentElement, {
@@ -248,7 +248,7 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
}, [monaco]);
useEffect(() => {
if (typeof window === "undefined") return;
if (typeof window === "undefined") return () => {};
const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_DIFF_BREAKPOINT}px)`);
const syncViewMode = () => {
if (!userSelectedViewModeRef.current) {
@@ -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}
+1 -4
View File
@@ -1,8 +1,5 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_SUMMARY,
} from "clawhub-schema/licenseConstants";
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
import { Calendar, Download, Package, Scale, Star, Tag } from "lucide-react";
import type { Id } from "../../convex/_generated/dataModel";
import { formatCompactStat } from "../lib/numberFormat";
+14 -13
View File
@@ -95,20 +95,21 @@ export function SoulDetailPage({ slug }: SoulDetailPageProps) {
}, [ensureSoulSeeds]);
useEffect(() => {
if (!latestVersion) return;
setReadme(null);
setReadmeError(null);
let cancelled = false;
void getReadme({ versionId: latestVersion._id })
.then((data) => {
if (cancelled) return;
setReadme(data.text);
})
.catch((error) => {
if (cancelled) return;
setReadmeError(error instanceof Error ? error.message : "Failed to load SOUL.md");
setReadme(null);
});
if (latestVersion) {
setReadme(null);
setReadmeError(null);
void getReadme({ versionId: latestVersion._id })
.then((data) => {
if (cancelled) return;
setReadme(data.text);
})
.catch((error) => {
if (cancelled) return;
setReadmeError(error instanceof Error ? error.message : "Failed to load SOUL.md");
setReadme(null);
});
}
return () => {
cancelled = true;
};
-3
View File
@@ -141,9 +141,6 @@ function rhex(n: number) {
}
function hex(x: number[]) {
for (let i = 0; i < x.length; i += 1) {
x[i] = Number(x[i]);
}
return x.map(rhex).join("");
}
+1 -1
View File
@@ -368,7 +368,7 @@ describe("fetchPackages", () => {
const result = await fetchPackageVersion("demo-plugin", "1.2.3+build/meta");
expect(result.version?.version).toBe("1.2.3");
expect(result?.version?.version).toBe("1.2.3");
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://registry.example/api/v1/packages/demo-plugin/versions/1.2.3%2Bbuild%2Fmeta",
);
+4 -4
View File
@@ -52,18 +52,18 @@ export function normalizePackageUploadPath(
return parts.slice(1).join("/") || (parts.at(-1) ?? "");
}
function getRawUploadPath<TFile extends UploadablePackageFile>(file: TFile) {
function getRawUploadPath(file: UploadablePackageFile) {
return file.webkitRelativePath?.trim() || file.name;
}
function getNormalizedUploadPath<TFile extends UploadablePackageFile>(
file: TFile,
function getNormalizedUploadPath(
file: UploadablePackageFile,
options: NormalizePackageUploadPathOptions = {},
) {
return normalizePackageUploadPath(getRawUploadPath(file), options) || file.name;
}
function shouldStripSharedTopLevelFolder<TFile extends UploadablePackageFile>(files: TFile[]) {
function shouldStripSharedTopLevelFolder(files: UploadablePackageFile[]) {
if (files.length === 0) return false;
const partsList = files
.map((file) => getNormalizedUploadPath(file))
+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 };
+26
View File
@@ -0,0 +1,26 @@
import type { Root } from "hast";
import { visit } from "unist-util-visit";
/**
* Routes external http(s) <img> sources through Vercel's image optimizer at
* /_vercel/image, which enforces the allow-list, SVG rejection, and caching
* declared in vercel.json. Local paths, relative paths, and data: URIs pass
* through unchanged only external schemes are treated as untrusted.
*
* `w` is required by the optimizer and must match a value in the `sizes`
* array in vercel.json, so we always pass 1024. The <img width="..."> HTML
* attribute still drives layout this only controls served resolution.
*/
export function rehypeProxyImages() {
return (tree: Root) => {
visit(tree, "element", (node) => {
if (node.tagName !== "img") return;
const src = node.properties?.src;
if (typeof src !== "string" || !/^https?:\/\//i.test(src)) return;
node.properties = {
...node.properties,
src: `/_vercel/image?url=${encodeURIComponent(src)}&w=1024&q=75`,
};
});
};
}
+1 -1
View File
@@ -29,5 +29,5 @@ export function isDevRuntime() {
if (nodeEnv) {
return nodeEnv !== "production";
}
return Boolean(import.meta.env.DEV);
return import.meta.env.DEV;
}
+9 -9
View File
@@ -14,8 +14,8 @@ describe("theme", () => {
<button type="button" onClick={() => setMode("dark")}>
dark
</button>
<button type="button" onClick={() => setFamily("hub")}>
hub
<button type="button" onClick={() => setFamily("claw")}>
claw
</button>
</div>
);
@@ -27,7 +27,7 @@ describe("theme", () => {
value: {
getItem: (key: string) => (key in store ? store[key] : null),
setItem: (key: string, value: string) => {
store[key] = String(value);
store[key] = value;
},
removeItem: (key: string) => {
delete store[key];
@@ -58,7 +58,7 @@ describe("theme", () => {
"clawhub-theme-selection",
JSON.stringify({ theme: "hub", mode: "light" }),
);
expect(getStoredThemeSelection()).toEqual({ theme: "hub", mode: "light" });
expect(getStoredThemeSelection()).toEqual({ theme: "claw", mode: "light" });
window.localStorage.clear();
window.localStorage.setItem("clawhub-theme", "dark");
@@ -70,10 +70,10 @@ describe("theme", () => {
});
it("applies family and resolved mode to the document", () => {
applyTheme("dark", "hub");
applyTheme("dark", "claw");
expect(document.documentElement.dataset.theme).toBe("dark");
expect(document.documentElement.dataset.themeResolved).toBe("dark");
expect(document.documentElement.dataset.themeFamily).toBe("hub");
expect(document.documentElement.dataset.themeFamily).toBe("claw");
expect(document.documentElement.classList.contains("dark")).toBe(true);
applyTheme("light", "claw");
@@ -104,15 +104,15 @@ describe("theme", () => {
expect(screen.getByTestId("mode").textContent).toBe("system");
expect(screen.getByTestId("family").textContent).toBe("claw");
fireEvent.click(screen.getByRole("button", { name: "hub" }));
fireEvent.click(screen.getByRole("button", { name: "claw" }));
fireEvent.click(screen.getByRole("button", { name: "dark" }));
await waitFor(() => {
expect(document.documentElement.dataset.themeFamily).toBe("hub");
expect(document.documentElement.dataset.themeFamily).toBe("claw");
expect(document.documentElement.dataset.themeResolved).toBe("dark");
});
expect(window.localStorage.getItem("clawhub-theme")).toBe("dark");
expect(window.localStorage.getItem("clawhub-theme-name")).toBe("hub");
expect(window.localStorage.getItem("clawhub-theme-name")).toBe("claw");
});
});
+2 -2
View File
@@ -166,13 +166,13 @@ export function useThemeMode() {
}, []);
useEffect(() => {
if (!isHydrated) return;
if (!isHydrated) return () => {};
applyThemeSelection(selection);
persistThemeSelection(selection);
syncCustomThemeFromStorage();
if (selection.mode !== 'system' || typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return;
return () => {};
}
const media = window.matchMedia('(prefers-color-scheme: dark)');
+1 -1
View File
@@ -55,7 +55,7 @@ export function useUnifiedSearch(
setPluginCount(0);
setUserCount(0);
setIsSearching(false);
return;
return () => {};
}
requestRef.current += 1;
+8 -1
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: [
@@ -117,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>
+2 -1
View File
@@ -9,11 +9,12 @@ import {
ShieldOff,
UserX,
} from 'lucide-react';
import type { ReactNode } from 'react';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { getSiteMode, getSiteName, getSiteUrlForMode } from '../lib/site';
export function renderWithInlineCode(text: string): (string | JSX.Element)[] {
export function renderWithInlineCode(text: string): ReactNode[] {
const parts = text.split(/(`[^`]+`)/g);
return parts.map((part, i) => {
if (part.startsWith('`') && part.endsWith('`')) {
+1 -1
View File
@@ -463,7 +463,7 @@ export function ImportGitHub() {
>
<input
type="checkbox"
checked={Boolean(selected[file.path])}
checked={selected[file.path]}
onChange={() =>
setSelected((prev) => ({ ...prev, [file.path]: !prev[file.path] }))
}
+21 -33
View File
@@ -7,12 +7,10 @@ import {
ChevronRight,
Code2,
Download,
Layers,
Package,
Search,
Shield,
Star,
Users,
Zap,
} from "lucide-react";
import { api } from "../../convex/_generated/api";
import { SoulCard } from "../components/SoulCard";
@@ -286,19 +284,18 @@ function SkillsHome() {
});
}
const drawClaw = (ctx: CanvasRenderingContext2D, size: number) => {
const drawClaw = (context: CanvasRenderingContext2D, size: number) => {
// Simple lobster claw shape
ctx.beginPath();
ctx.moveTo(0, size * 0.5);
ctx.quadraticCurveTo(-size * 0.6, size * 0.2, -size * 0.4, -size * 0.3);
ctx.quadraticCurveTo(-size * 0.2, -size * 0.6, 0, -size * 0.3);
ctx.quadraticCurveTo(size * 0.2, -size * 0.6, size * 0.4, -size * 0.3);
ctx.quadraticCurveTo(size * 0.6, size * 0.2, 0, size * 0.5);
ctx.closePath();
ctx.fill();
context.beginPath();
context.moveTo(0, size * 0.5);
context.quadraticCurveTo(-size * 0.6, size * 0.2, -size * 0.4, -size * 0.3);
context.quadraticCurveTo(-size * 0.2, -size * 0.6, 0, -size * 0.3);
context.quadraticCurveTo(size * 0.2, -size * 0.6, size * 0.4, -size * 0.3);
context.quadraticCurveTo(size * 0.6, size * 0.2, 0, size * 0.5);
context.closePath();
context.fill();
};
let raf: number;
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
let alive = false;
@@ -334,13 +331,13 @@ function SkillsHome() {
ctx.restore();
}
if (alive) {
raf = requestAnimationFrame(draw);
requestAnimationFrame(draw);
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.style.display = "none";
}
};
raf = requestAnimationFrame(draw);
requestAnimationFrame(draw);
};
const renderSlotReel = (reelIdx: 0 | 1 | 2) => {
@@ -440,7 +437,8 @@ function SkillsHome() {
/>
<kbd>/</kbd>
<button type="submit" className="home-v2-search-go">
Search <ArrowRight size={16} />
<span className="home-v2-search-go-label">Search</span>{" "}
<ArrowRight size={16} />
</button>
</form>
</div>
@@ -452,28 +450,28 @@ function SkillsHome() {
className="home-v2-suggestion"
onClick={() => handleSuggestion("self-improving agent")}
>
<Zap size={13} /> self-improving agent
self-improving agent
</button>
<button
type="button"
className="home-v2-suggestion"
onClick={() => handleSuggestion("GitHub integration")}
>
<Code2 size={13} /> GitHub integration
GitHub integration
</button>
<button
type="button"
className="home-v2-suggestion"
onClick={() => handleSuggestion("security soul")}
>
<Shield size={13} /> security soul
security soul
</button>
<button
type="button"
className="home-v2-suggestion"
onClick={() => handleSuggestion("dashboard builder")}
>
<Layers size={13} /> dashboard builder
dashboard builder
</button>
</div>
</section>
@@ -502,9 +500,6 @@ function SkillsHome() {
className="home-v2-c-card"
>
<div className="home-v2-c-head">
<div className="home-v2-c-icon">
<Zap size={18} />
</div>
<div className="home-v2-c-meta">
<div className="home-v2-c-name">
{entry.skill.displayName || entry.skill.slug}
@@ -514,9 +509,7 @@ function SkillsHome() {
</div>
</div>
</div>
<span className="home-v2-c-tag">
<Zap size={11} /> Skill
</span>
<span className="home-v2-c-tag">Skill</span>
<div className="home-v2-c-desc">
{entry.skill.summary || "A fresh skill bundle."}
</div>
@@ -545,9 +538,6 @@ function SkillsHome() {
className="home-v2-c-card"
>
<div className="home-v2-c-head">
<div className="home-v2-c-icon">
<Zap size={18} />
</div>
<div className="home-v2-c-meta">
<div className="home-v2-c-name">
{entry.skill.displayName || entry.skill.slug}
@@ -557,9 +547,7 @@ function SkillsHome() {
</div>
</div>
</div>
<span className="home-v2-c-tag">
<Zap size={11} /> Skill
</span>
<span className="home-v2-c-tag">Skill</span>
<div className="home-v2-c-desc">
{entry.skill.summary || "A fresh skill bundle."}
</div>
@@ -602,7 +590,7 @@ function SkillsHome() {
className="home-v2-cat-item"
>
<div className="home-v2-cat-icon">
<Zap size={20} />
<Package size={20} />
</div>
<div className="home-v2-cat-text">
<div className="home-v2-cat-name">Skills</div>
+1 -1
View File
@@ -438,7 +438,7 @@ function PluginDetailRoute() {
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{String(value)}
{value}
</dd>
</div>
))}
+3 -10
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";
@@ -495,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>
@@ -533,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>
@@ -604,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>
+2 -2
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";
@@ -64,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} />,
};
+3 -3
View File
@@ -146,7 +146,7 @@ export function useSkillsBrowseModel({
}, [searchKey]);
useEffect(() => {
if (!hasQuery) return;
if (!hasQuery) return () => {};
searchRequest.current += 1;
const requestId = searchRequest.current;
setIsSearching(true);
@@ -269,9 +269,9 @@ export function useSkillsBrowseModel({
}, [isLoadingMore]);
useEffect(() => {
if (!canLoadMore || typeof IntersectionObserver === "undefined") return;
if (!canLoadMore || typeof IntersectionObserver === "undefined") return () => {};
const target = loadMoreRef.current;
if (!target) return;
if (!target) return () => {};
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
+1 -1
View File
@@ -244,7 +244,7 @@ function InstalledSection(props: {
<div key={`${root.rootId}:${entry.skill.slug}`} className="telemetry-skill-row">
<a
className="telemetry-skill-link"
href={`/${encodeURIComponent(String(entry.skill.ownerUserId))}/${entry.skill.slug}`}
href={`/${encodeURIComponent(entry.skill.ownerUserId)}/${entry.skill.slug}`}
>
<span>{entry.skill.displayName}</span>
<span className="telemetry-skill-slug">/{entry.skill.slug}</span>
+227 -239
View File
@@ -93,13 +93,13 @@
color-scheme: dark;
/* OpenClaw — black, white, red brand palette */
--bg: #0a0a0a;
--bg-soft: #111111;
--bg-glow-1: #141414;
--bg-glow-2: #181818;
--surface: #121212;
--surface-muted: #171717;
--nav-bg: rgba(10, 10, 10, 0.96);
--bg: #060608;
--bg-soft: #0d0d0f;
--bg-glow-1: #101012;
--bg-glow-2: #141416;
--surface: #0e0e10;
--surface-muted: #131315;
--nav-bg: rgba(6, 6, 8, 0.96);
--ink: #fafafa;
--ink-soft: #a1a1a1;
--accent: #dc2626;
@@ -130,7 +130,7 @@
/* Form controls — crisp, modern */
--input-border: rgba(255, 255, 255, 0.1);
--input-bg: rgba(18, 18, 18, 0.9);
--input-bg: rgba(14, 14, 16, 0.9);
--input-placeholder: rgba(161, 161, 161, 0.7);
--input-focus-border: rgba(220, 38, 38, 0.5);
--input-focus-ring: rgba(220, 38, 38, 0.2);
@@ -141,7 +141,7 @@
--active-bg: rgba(220, 38, 38, 0.1);
/* Overlay */
--overlay-bg: rgba(9, 9, 11, 0.75);
--overlay-bg: rgba(5, 5, 7, 0.75);
/* Shadows — refined depth */
--shadow-dialog: 0 24px 48px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
@@ -173,12 +173,12 @@
--fs-2xl: 1.5rem;
--fs-3xl: 2rem;
/* Modern rounded corners — polished but efficient */
--r-lg: 12px;
/* Unified radius — one consistent size everywhere */
--r-lg: 8px;
--r-md: 8px;
--r-sm: 6px;
--r-xs: 4px;
--r-pill: 9999px;
--r-sm: 8px;
--r-xs: 8px;
--r-pill: 8px;
/* Modern typography — clean and readable */
--font-display: "Bricolage Grotesque", "Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
@@ -189,11 +189,11 @@
/* Light theme — OpenClaw black, white, red */
[data-theme-family="claw"][data-theme-resolved="light"] {
color-scheme: light;
--bg: #fafafa;
--bg-soft: #f5f5f5;
--surface: #ffffff;
--surface-muted: #f8f8f8;
--nav-bg: rgba(250, 250, 250, 0.96);
--bg: #faf6f1;
--bg-soft: #f5f1ec;
--surface: #fffcf8;
--surface-muted: #f8f5f0;
--nav-bg: rgba(250, 246, 241, 0.96);
--ink: #0a0a0a;
--ink-soft: #525252;
--accent: #dc2626;
@@ -215,7 +215,7 @@
/* Form controls — light */
--input-border: rgba(0, 0, 0, 0.12);
--input-bg: #ffffff;
--input-bg: #fffcf8;
--input-placeholder: rgba(82, 82, 82, 0.6);
--input-focus-border: rgba(220, 38, 38, 0.5);
--input-focus-ring: rgba(220, 38, 38, 0.15);
@@ -226,7 +226,7 @@
--active-bg: rgba(220, 38, 38, 0.08);
/* Overlay — light */
--overlay-bg: rgba(250, 250, 250, 0.75);
--overlay-bg: rgba(250, 246, 241, 0.75);
/* Shadows — light */
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
@@ -241,11 +241,11 @@
[data-theme-family="hub"] {
--page-max: 1536px;
--page-narrow: 900px;
--r-lg: 2px;
--r-md: 2px;
--r-sm: 1px;
--r-xs: 1px;
--r-pill: 2px;
--r-lg: 8px;
--r-md: 8px;
--r-sm: 8px;
--r-xs: 8px;
--r-pill: 8px;
--fs-xs: 0.72rem;
--fs-sm: 0.82rem;
--fs-base: 0.88rem;
@@ -263,13 +263,13 @@
[data-theme-family="hub"][data-theme-resolved="dark"] {
color-scheme: dark;
--bg: #0a0a0a;
--bg-soft: #111111;
--bg-glow-1: #111111;
--bg-glow-2: #111111;
--surface: #141414;
--surface-muted: #1a1a1a;
--nav-bg: rgba(10, 10, 10, 0.95);
--bg: #060608;
--bg-soft: #0d0d0f;
--bg-glow-1: #0d0d0f;
--bg-glow-2: #0d0d0f;
--surface: #101012;
--surface-muted: #161618;
--nav-bg: rgba(6, 6, 8, 0.95);
--ink: #e0e0e0;
--ink-soft: #818181;
--accent: #ef4444;
@@ -294,25 +294,25 @@
--status-error-bg: rgba(239, 68, 68, 0.12);
--status-error-fg: #f87171;
--input-border: rgba(255, 255, 255, 0.12);
--input-bg: rgba(20, 20, 20, 0.96);
--input-bg: rgba(16, 16, 18, 0.96);
--input-placeholder: rgba(184, 184, 184, 0.68);
--input-focus-border: rgba(239, 68, 68, 0.48);
--input-focus-ring: rgba(239, 68, 68, 0.16);
--label-fg: rgba(224, 224, 224, 0.78);
--hover-bg: rgba(255, 255, 255, 0.03);
--active-bg: rgba(239, 68, 68, 0.1);
--overlay-bg: rgba(10, 10, 10, 0.66);
--overlay-bg: rgba(6, 6, 8, 0.66);
--shadow-dialog: 0 24px 50px rgba(0, 0, 0, 0.35);
--shadow-card: none;
}
[data-theme-family="hub"][data-theme-resolved="light"] {
color-scheme: light;
--bg: #f0f0f0;
--bg-soft: #e8e8e8;
--surface: #ffffff;
--surface-muted: #f5f5f5;
--nav-bg: rgba(240, 240, 240, 0.95);
--bg: #ece8e3;
--bg-soft: #e4e0db;
--surface: #fdfaf6;
--surface-muted: #f5f1ec;
--nav-bg: rgba(236, 232, 227, 0.95);
--ink: #0a0a0a;
--ink-soft: #555555;
--accent: #dc2626;
@@ -337,14 +337,14 @@
--status-error-bg: rgba(220, 38, 38, 0.1);
--status-error-fg: #b91c1c;
--input-border: rgba(0, 0, 0, 0.18);
--input-bg: rgba(255, 255, 255, 0.94);
--input-bg: rgba(253, 250, 246, 0.94);
--input-placeholder: rgba(85, 85, 85, 0.64);
--input-focus-border: rgba(220, 38, 38, 0.42);
--input-focus-ring: rgba(220, 38, 38, 0.12);
--label-fg: rgba(10, 10, 10, 0.72);
--hover-bg: rgba(0, 0, 0, 0.03);
--active-bg: rgba(220, 38, 38, 0.08);
--overlay-bg: rgba(240, 240, 240, 0.7);
--overlay-bg: rgba(236, 232, 227, 0.7);
--shadow-dialog: 0 24px 50px rgba(0, 0, 0, 0.12);
--shadow-card: none;
}
@@ -640,15 +640,15 @@ code {
}
.site-footer {
padding: var(--space-6) var(--space-5);
padding: var(--space-3) var(--space-5);
margin-top: auto;
background: var(--surface);
border-top: 1px solid var(--line);
}
.site-footer-inner {
max-width: var(--page-max);
margin: 0 auto;
width: 100%;
padding: 0 var(--space-5);
}
.site-footer-divider {
@@ -700,8 +700,7 @@ code {
}
.navbar-inner {
max-width: var(--page-max);
margin: 0 auto;
width: 100%;
padding: 0 var(--space-5);
display: flex;
flex-direction: column;
@@ -913,6 +912,11 @@ code {
place-items: center;
overflow: hidden;
background: transparent;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--ink) 8%, transparent),
0 1px 2px rgba(0, 0, 0, 0.10),
0 2px 6px rgba(0, 0, 0, 0.05);
transition: box-shadow 0.2s ease;
}
.brand-mark img {
@@ -920,6 +924,7 @@ code {
height: 100%;
object-fit: cover;
border-radius: var(--r-sm);
transform: translateZ(0);
}
.nav-links {
@@ -976,6 +981,39 @@ code {
color: var(--ink-soft);
}
.mobile-nav-brand {
display: inline-flex;
align-items: center;
gap: 10px;
}
.mobile-nav-brand-mark {
width: 28px;
height: 28px;
border-radius: var(--r-sm);
display: grid;
place-items: center;
overflow: hidden;
background: transparent;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--ink) 8%, transparent),
0 1px 2px rgba(0, 0, 0, 0.08);
flex-shrink: 0;
}
.mobile-nav-brand-mark-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: var(--r-sm);
transform: translateZ(0);
}
.mobile-nav-brand-name {
display: inline-flex;
min-width: 0;
}
.mobile-nav-link {
display: inline-flex;
align-items: center;
@@ -1993,7 +2031,7 @@ code {
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr));
gap: 16px;
max-width: 100%;
}
@@ -3356,7 +3394,7 @@ code {
border: 1px solid var(--line);
background: var(--surface-muted);
text-align: right;
min-width: 150px;
min-width: 0;
}
.skill-version-label {
@@ -3407,7 +3445,7 @@ code {
.skill-hero-panels {
display: grid;
gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
}
.skill-panel {
@@ -3565,6 +3603,10 @@ code {
max-height: 220px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.diff-monaco {
max-height: 300px;
}
}
.diff-pill {
@@ -3719,6 +3761,15 @@ code {
border: 1px solid var(--line);
background: var(--surface-muted);
align-self: flex-start;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
max-width: 100%;
flex-shrink: 0;
}
.tab-header::-webkit-scrollbar {
display: none;
}
.tab-button {
@@ -3730,6 +3781,8 @@ code {
color: var(--ink-soft);
cursor: pointer;
min-height: 44px;
white-space: nowrap;
flex-shrink: 0;
}
.tab-button.is-active {
@@ -4158,9 +4211,18 @@ code {
}
.skill-hero-cta {
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
}
.skill-hero-cta .btn {
width: auto;
flex: 1 1 auto;
min-width: 0;
}
.tag-form {
grid-template-columns: 1fr;
align-items: stretch;
@@ -4202,6 +4264,14 @@ code {
row-gap: 8px;
}
.browse-search-input {
font-size: 16px;
}
.navbar-search-input {
font-size: 16px;
}
}
@media (max-width: 520px) {
@@ -4267,7 +4337,7 @@ code {
}
.site-footer {
padding: 0 18px 36px;
padding: 0 18px 18px;
}
}
@@ -5918,7 +5988,12 @@ html.theme-transition::view-transition-new(theme) {
}
.skill-hero-title h1 {
font-size: 1.35rem;
font-size: 1.25rem;
line-height: 1.3;
}
.skill-hero-note {
font-size: 0.82rem;
}
.card {
@@ -5929,6 +6004,43 @@ html.theme-transition::view-transition-new(theme) {
padding: 16px;
}
.detail-meta-bar {
padding: var(--space-3);
}
.meta-bar-stats {
gap: var(--space-3);
}
.skill-hero-sidebar-meta {
min-width: 0;
}
.file-viewer {
padding: 12px;
}
.file-viewer-body {
max-height: 260px;
}
.file-list-body {
max-height: 200px;
}
.comment-entry {
padding: var(--space-2);
}
.markdown pre {
font-size: 0.78rem;
}
.markdown img {
max-width: 100%;
height: auto;
}
.scan-result-row {
grid-template-columns: 1fr auto;
gap: 6px 10px;
@@ -6568,6 +6680,8 @@ html.theme-transition::view-transition-new(theme) {
@media (max-width: 560px) {
.skill-list-item {
grid-template-columns: 1fr;
padding: 14px 16px;
gap: 12px;
}
.marketplace-icon {
@@ -6664,6 +6778,7 @@ html.theme-transition::view-transition-new(theme) {
border-radius: var(--r-sm);
background: var(--surface);
cursor: pointer;
min-height: 44px;
color: var(--ink-soft);
}
@@ -6864,7 +6979,21 @@ html.theme-transition::view-transition-new(theme) {
@media (max-width: 760px) {
.browse-page {
padding: 16px 18px 40px;
padding: 16px 16px 40px;
}
.browse-page-search {
height: 44px;
}
.browse-results-toolbar {
flex-wrap: wrap;
gap: 8px;
}
.browse-view-btn {
min-height: 44px;
padding: var(--space-2) var(--space-3);
}
.browse-layout {
@@ -7112,14 +7241,16 @@ html.theme-transition::view-transition-new(theme) {
.footer-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-5);
padding: var(--space-6) 0 var(--space-5);
gap: var(--space-3);
padding: var(--space-3) 0 var(--space-2);
text-align: center;
}
.footer-col {
display: flex;
flex-direction: column;
gap: var(--space-2);
align-items: center;
gap: var(--space-1);
}
.footer-col-title {
@@ -7128,7 +7259,7 @@ html.theme-transition::view-transition-new(theme) {
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-soft);
margin-bottom: var(--space-1);
margin-bottom: 2px;
}
.footer-col a,
@@ -7143,7 +7274,7 @@ html.theme-transition::view-transition-new(theme) {
.footer-bottom {
border-top: 1px solid var(--line);
padding: var(--space-4) 0;
padding: var(--space-2) 0;
text-align: center;
font-size: var(--fs-sm);
letter-spacing: 0.015em;
@@ -7165,14 +7296,14 @@ html.theme-transition::view-transition-new(theme) {
@media (max-width: 760px) {
.footer-grid {
grid-template-columns: repeat(2, 1fr);
gap: 20px;
gap: var(--space-3);
}
}
@media (max-width: 520px) {
.footer-grid {
grid-template-columns: 1fr;
gap: 20px;
gap: var(--space-3);
}
}
@@ -7838,7 +7969,7 @@ html.theme-transition::view-transition-new(theme) {
height: 1.15em;
display: flex;
align-items: center;
justify-content: center;
justify-content: left;
flex-shrink: 0;
white-space: nowrap;
}
@@ -8108,6 +8239,24 @@ html.theme-transition::view-transition-new(theme) {
color: var(--hv2-accent);
}
@media (max-width: 760px) {
.home-v2-suggestions {
gap: 6px;
margin-top: 12px;
}
.home-v2-suggestions-label {
font-size: 12px;
margin-right: 2px;
}
.home-v2-suggestion {
font-size: 12px;
padding: 5px 10px;
gap: 4px;
}
}
/* ═══ CAROUSEL ═══ */
.home-v2-carousel-section {
padding: 48px 0 0;
@@ -8689,6 +8838,12 @@ html.theme-transition::view-transition-new(theme) {
padding: 20px;
gap: 16px;
}
.home-v2-search-go-label {
display: none;
}
.home-v2-search-go {
padding: 13px 16px;
}
}
/* ═══ HOME V2 — Full-width overrides ═══ */
@@ -8705,57 +8860,7 @@ html.theme-transition::view-transition-new(theme) {
max-width: none;
}
/* ═══ MINIMAL FOOTER (home-v2 pages) ═══ */
/* Fix: use :has() since main is wrapped in ErrorBoundary */
.app-shell:has(.home-v2-main) > .site-footer .footer-grid {
display: none;
}
.app-shell:has(.home-v2-main) > .site-footer .site-footer-divider {
display: none;
}
.app-shell:has(.home-v2-main) > .site-footer {
border-top: 1px solid rgba(255,255,255,0.06);
padding: 0;
background: transparent;
}
.app-shell:has(.home-v2-main) > .site-footer .site-footer-inner {
max-width: none;
padding: 20px 48px;
}
.app-shell:has(.home-v2-main) > .site-footer .footer-bottom {
padding: 0;
margin: 0;
border: none;
font-size: 13px;
color: #555;
display: flex;
align-items: center;
justify-content: space-between;
}
.app-shell:has(.home-v2-main) > .site-footer .footer-bottom a {
color: #777;
}
.app-shell:has(.home-v2-main) > .site-footer .footer-bottom a:hover {
color: #aaa;
}
/* ═══ HOME V2 — Page-max constraint + full-bleed background ═══ */
.app-shell:has(.home-v2-main) {
background: #060608;
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) {
background: #faf6f1;
}
/* ═══ HOME V2 — Full-width nav override ═══ */
.app-shell:has(.home-v2-main) > header,
.app-shell:has(.home-v2-main) > nav,
.app-shell:has(.home-v2-main) .site-header,
.app-shell:has(.home-v2-main) .site-header-inner {
max-width: none;
width: 100%;
}
/* ═══ HOME V2 — Layout ═══ */
/* ═══ HOME V2 — Wider search + even dot spacing ═══ */
.home-v2-search-container {
@@ -8779,10 +8884,6 @@ html.theme-transition::view-transition-new(theme) {
margin: 0 16px;
}
/* Override navbar max-width on home v2 */
.app-shell:has(.home-v2-main) .navbar-inner {
max-width: none;
}
/*
HOME V2 Cream / Peach / Tan Refinement (light + dark)
@@ -9025,55 +9126,6 @@ html.theme-transition::view-transition-new(theme) {
border-top-color: rgba(170, 125, 80, 0.12);
}
/* Light — footer */
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) > .site-footer {
border-top-color: rgba(170, 125, 80, 0.15);
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) > .site-footer .footer-bottom {
color: #9c8b7a;
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) > .site-footer .footer-bottom a {
color: #6b5c4e;
}
/* Light — nav/header blends with cream bg */
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar {
background: rgba(250, 246, 241, 0.95);
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .brand,
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .brand-name {
color: #1a1410;
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-search-home {
background: #fff8f2;
border-color: rgba(170, 125, 80, 0.2);
color: #6b5c4e;
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-search-home:hover {
border-color: rgba(170, 125, 80, 0.3);
background: #fff4ea;
color: #1a1410;
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-tab {
color: #6b5c4e;
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-tab:hover {
color: #1a1410;
background: rgba(160, 115, 72, 0.07);
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-tab.active,
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-tab[data-status="active"] {
color: #1a1410;
background: rgba(160, 115, 72, 0.1);
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-tab-secondary {
opacity: 1;
color: #7d6d5f;
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-tab-secondary:hover {
color: #1a1410;
}
/* Light — carousel nav arrows */
[data-theme-resolved="light"] .home-v2-carousel-nav button {
background: #fff8f2;
@@ -9134,44 +9186,6 @@ html.theme-transition::view-transition-new(theme) {
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.1);
}
/* Home v2 nav contrast */
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar {
background: rgba(6, 6, 8, 0.9);
border-bottom-color: rgba(255, 255, 255, 0.08);
}
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .brand,
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .brand-name {
color: rgba(255, 255, 255, 0.94);
}
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-search-home {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.72);
}
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-search-home:hover {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.9);
}
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-tab {
color: rgba(255, 255, 255, 0.74);
}
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-tab:hover {
color: rgba(255, 255, 255, 0.96);
background: rgba(255, 255, 255, 0.04);
}
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-tab.active,
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-tab[data-status="active"] {
color: rgba(255, 255, 255, 0.98);
background: rgba(255, 255, 255, 0.06);
}
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-tab-secondary {
opacity: 1;
color: rgba(255, 255, 255, 0.68);
}
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-tab-secondary:hover {
color: rgba(255, 255, 255, 0.9);
}
/*
HOME V2 Semi-rounded radius (Claw × Hub midpoint)
@@ -9179,45 +9193,19 @@ html.theme-transition::view-transition-new(theme) {
Claw: 6/8/12 Hub: 1/2/2 Ours: 4/7/10
*/
/* Override the home-v2 radius tokens */
/* Override the home-v2 radius tokens — unified 8px */
.home-v2-main {
--hv2-radius-sm: 4px;
--hv2-radius-md: 7px;
--hv2-radius-lg: 10px;
--hv2-radius-sm: 8px;
--hv2-radius-md: 8px;
--hv2-radius-lg: 8px;
}
/* Search bar — was 16px, now semi-rounded */
.home-v2-search-bar {
border-radius: 10px;
}
/* Search button — was 12px */
.home-v2-search-go {
border-radius: 7px;
}
/* kbd badge — was 5px */
.home-v2-search-bar kbd {
border-radius: 3px;
}
/* Carousel cards — picks up var(--hv2-radius-lg) = 10px ✓ */
/* Trending cards — picks up var(--hv2-radius-lg) = 10px ✓ */
/* Carousel card icon — was 10px */
.home-v2-c-icon {
border-radius: 7px;
}
/* Tag pills — was 100px (full pill), bring to semi-rounded pill */
.home-v2-c-tag {
border-radius: 20px;
}
/* Category icon — was 11px */
.home-v2-cat-icon {
border-radius: 7px;
}
/* Install buttons — picks up var(--hv2-radius-sm) = 4px ✓ */
.home-v2-search-bar { border-radius: 8px; }
.home-v2-search-go { border-radius: 8px; }
.home-v2-search-bar kbd { border-radius: 8px; }
.home-v2-c-icon { border-radius: 8px; }
.home-v2-c-tag { border-radius: 8px; }
.home-v2-cat-icon { border-radius: 8px; }
/* Suggestion pills — were likely pill-shaped, soften */
.home-v2-suggestion {
+25 -1
View File
@@ -30,5 +30,29 @@
"source": "/api/:path*",
"destination": "https://wry-manatee-359.convex.site/api/:path*"
}
]
],
"images": {
"sizes": [256, 640, 1024, 1920],
"formats": ["image/webp"],
"minimumCacheTTL": 86400,
"dangerouslyAllowSVG": false,
"remotePatterns": [
{ "protocol": "https", "hostname": "raw.githubusercontent.com" },
{ "protocol": "https", "hostname": "user-images.githubusercontent.com" },
{ "protocol": "https", "hostname": "avatars.githubusercontent.com" },
{ "protocol": "https", "hostname": "camo.githubusercontent.com" },
{ "protocol": "https", "hostname": "github.com" },
{ "protocol": "https", "hostname": "raw.github.com" },
{ "protocol": "https", "hostname": "img.shields.io" },
{ "protocol": "https", "hostname": "shields.io" },
{ "protocol": "https", "hostname": "cdn.jsdelivr.net" },
{ "protocol": "https", "hostname": "i.imgur.com" },
{ "protocol": "https", "hostname": "codecov.io" },
{ "protocol": "https", "hostname": "coveralls.io" },
{ "protocol": "https", "hostname": "codefactor.io" },
{ "protocol": "https", "hostname": "badgen.net" },
{ "protocol": "https", "hostname": "flat.badgen.net" },
{ "protocol": "https", "hostname": "gitlab.com" }
]
}
}