Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea0824878d | ||
|
|
4787be4eb1 | ||
|
|
89246f1927 | ||
|
|
f4ddccbead | ||
|
|
3cafcbf873 | ||
|
|
13064a7897 | ||
|
|
194c22f4dd | ||
|
|
a693b945fa | ||
|
|
9bef672541 | ||
|
|
9551cac37b | ||
|
|
eb4138fbb3 | ||
|
|
5fbead624b | ||
|
|
35094177e6 | ||
|
|
faa5c9f2b5 | ||
|
|
c3314c2d01 | ||
|
|
7dfa19157c | ||
|
|
44acf86ac1 | ||
|
|
a0ebc1b50a | ||
|
|
88dbb69a23 | ||
|
|
df9acd27e4 | ||
|
|
dbd5d4042c | ||
|
|
ebe82b7e18 | ||
|
|
530e39eedc |
@@ -95,3 +95,23 @@ When working on Convex code, **always read `convex/_generated/ai/guidelines.md`
|
||||
|
||||
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
|
||||
<!-- convex-ai-end -->
|
||||
|
||||
## Stat Field Migration Rules
|
||||
|
||||
The `skills` table maintains two parallel sets of stat fields as part of an in-progress field migration:
|
||||
|
||||
| Legacy (nested, `@deprecated`) | Top-level (source of truth, indexable) |
|
||||
|---|---|
|
||||
| `stats.downloads` | `statsDownloads` |
|
||||
| `stats.stars` | `statsStars` |
|
||||
| `stats.installsCurrent` | `statsInstallsCurrent` |
|
||||
| `stats.installsAllTime` | `statsInstallsAllTime` |
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Always use `readCanonicalStat(skill, field)` (`convex/lib/skillStats.ts`) to read** any of the four migrated fields. It prefers the top-level field and falls back to the nested field for pre-migration documents. Never access `skill.stats.downloads` / `.stars` / `.installsCurrent` / `.installsAllTime` directly.
|
||||
- **Always use `applySkillStatDeltas()` to write** stat deltas. It writes both the top-level and nested fields in the same patch to keep them in sync.
|
||||
- **Both sets of fields must be written together** in any patch that touches stat values (see the return shape of `applySkillStatDeltas`).
|
||||
- **Nested-only reads are acceptable only for** `stats.comments` and `stats.versions` — no top-level field exists for these yet.
|
||||
- The four legacy nested fields are marked `@deprecated` in `statsValidator` (schema.ts). Any IDE access to `skill.stats.downloads` etc. will show a strikethrough warning — treat this as a signal to use `readCanonicalStat()` instead.
|
||||
- When adding new stat fields, follow the same dual-write pattern and add a cursor-based backfill mutation (see `backfillSkillStatFieldsInternal` for an example).
|
||||
|
||||
@@ -5,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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -49,6 +51,8 @@
|
||||
"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",
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 394 KiB |
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
@@ -76,6 +77,8 @@
|
||||
"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",
|
||||
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 972 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 972 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 792 B |
@@ -1,45 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
|
||||
<rect width="512" height="512" rx="0" fill="none"/>
|
||||
<!-- Body segments -->
|
||||
<g fill="#F03020" stroke="#fff" stroke-width="6" stroke-linejoin="round">
|
||||
<!-- Tail fan - center -->
|
||||
<ellipse cx="256" cy="460" rx="42" ry="30"/>
|
||||
<!-- Tail fan - left -->
|
||||
<ellipse cx="210" cy="455" rx="32" ry="24" transform="rotate(25 210 455)"/>
|
||||
<!-- Tail fan - right -->
|
||||
<ellipse cx="302" cy="455" rx="32" ry="24" transform="rotate(-25 302 455)"/>
|
||||
<!-- Lower body segment -->
|
||||
<rect x="208" y="380" width="96" height="55" rx="12"/>
|
||||
<!-- Mid-lower body -->
|
||||
<rect x="204" y="335" width="104" height="55" rx="12"/>
|
||||
<!-- Mid body -->
|
||||
<rect x="200" y="290" width="112" height="55" rx="14"/>
|
||||
<!-- Upper body / thorax -->
|
||||
<ellipse cx="256" cy="255" rx="72" ry="65"/>
|
||||
<!-- Head -->
|
||||
<circle cx="256" cy="195" r="58"/>
|
||||
<!-- Left claw arm -->
|
||||
<path d="M195 230 Q150 190 120 170 Q100 155 95 140" stroke-width="18" fill="none" stroke-linecap="round"/>
|
||||
<!-- Right claw arm -->
|
||||
<path d="M317 230 Q362 190 392 170 Q412 155 417 140" stroke-width="18" fill="none" stroke-linecap="round"/>
|
||||
<!-- Left claw -->
|
||||
<ellipse cx="88" cy="115" rx="52" ry="42" transform="rotate(-20 88 115)"/>
|
||||
<!-- Right claw -->
|
||||
<ellipse cx="424" cy="115" rx="52" ry="42" transform="rotate(20 424 115)"/>
|
||||
<!-- Left claw notch -->
|
||||
<path d="M65 95 Q78 80 72 65" stroke-width="6" fill="none" stroke="#fff" stroke-linecap="round"/>
|
||||
<!-- Right claw notch -->
|
||||
<path d="M447 95 Q434 80 440 65" stroke-width="6" fill="none" stroke="#fff" stroke-linecap="round"/>
|
||||
<!-- Left legs -->
|
||||
<path d="M205 290 Q175 295 155 305" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<path d="M202 320 Q172 330 152 340" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<path d="M205 350 Q178 358 158 370" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<!-- Right legs -->
|
||||
<path d="M307 290 Q337 295 357 305" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<path d="M310 320 Q340 330 360 340" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<path d="M307 350 Q334 358 354 370" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
</g>
|
||||
<!-- Eyes -->
|
||||
<circle cx="236" cy="185" r="10" fill="#000"/>
|
||||
<circle cx="276" cy="185" r="10" fill="#000"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 285 KiB |
@@ -21,5 +21,5 @@
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
"background_color": "#0a0a0a"
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 281 KiB |
@@ -1,98 +1,102 @@
|
||||
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1200" y2="630" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#14110F"/>
|
||||
<stop offset="0.55" stop-color="#1A1512"/>
|
||||
<stop offset="1" stop-color="#14110F"/>
|
||||
</linearGradient>
|
||||
|
||||
<radialGradient id="glowOrange" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(260 60) rotate(120) scale(520 420)">
|
||||
<stop stop-color="#E86A47" stop-opacity="0.55"/>
|
||||
<stop offset="1" stop-color="#E86A47" stop-opacity="0"/>
|
||||
<radialGradient id="bgGlowRight" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(1048 328) rotate(180) scale(358 260)">
|
||||
<stop stop-color="#7B1F18" stop-opacity="0.58"/>
|
||||
<stop offset="0.45" stop-color="#431210" stop-opacity="0.26"/>
|
||||
<stop offset="1" stop-color="#030305" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
|
||||
<radialGradient id="glowSea" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(1050 120) rotate(140) scale(520 420)">
|
||||
<stop stop-color="#4AD8B7" stop-opacity="0.35"/>
|
||||
<stop offset="1" stop-color="#4AD8B7" stop-opacity="0"/>
|
||||
<radialGradient id="bgGlowBottom" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(192 610) rotate(-90) scale(180 420)">
|
||||
<stop stop-color="#A12A1D" stop-opacity="0.2"/>
|
||||
<stop offset="1" stop-color="#030305" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
|
||||
<filter id="softBlur" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation="24"/>
|
||||
</filter>
|
||||
|
||||
<filter id="cardShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="26" flood-color="#000000" flood-opacity="0.6"/>
|
||||
</filter>
|
||||
|
||||
<linearGradient id="pill" x1="0" y1="0" x2="360" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E86A47" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="#E86A47" stop-opacity="0.08"/>
|
||||
<linearGradient id="frameStroke" x1="40" y1="106" x2="1146" y2="530" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6D3437" stop-opacity="0.8"/>
|
||||
<stop offset="0.55" stop-color="#DF5D35" stop-opacity="0.36"/>
|
||||
<stop offset="1" stop-color="#FF6D39" stop-opacity="0.9"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="stroke" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop stop-color="#FFFFFF" stop-opacity="0.16"/>
|
||||
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.06"/>
|
||||
<linearGradient id="frameGlow" x1="164" y1="164" x2="1114" y2="476" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#130D10"/>
|
||||
<stop offset="0.5" stop-color="#170B0E"/>
|
||||
<stop offset="1" stop-color="#261011"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="logoStroke" x1="112" y1="140" x2="398" y2="430" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4C2A28" stop-opacity="0.55"/>
|
||||
<stop offset="1" stop-color="#E05831" stop-opacity="0.28"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="searchStroke" x1="146" y1="480" x2="1068" y2="480" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7F342A" stop-opacity="0.65"/>
|
||||
<stop offset="1" stop-color="#FF6F37" stop-opacity="0.85"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="buttonFill" x1="838" y1="445" x2="1084" y2="510" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#D55335"/>
|
||||
<stop offset="1" stop-color="#EB6A3E"/>
|
||||
</linearGradient>
|
||||
<filter id="softBlur" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feGaussianBlur stdDeviation="20"/>
|
||||
</filter>
|
||||
<filter id="glowBlur" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation="10"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||
<circle cx="260" cy="60" r="520" fill="url(#glowOrange)" filter="url(#softBlur)"/>
|
||||
<circle cx="1050" cy="120" r="520" fill="url(#glowSea)" filter="url(#softBlur)"/>
|
||||
<rect width="1200" height="630" fill="#030305"/>
|
||||
<rect width="1200" height="630" fill="url(#bgGlowRight)"/>
|
||||
<rect width="1200" height="630" fill="url(#bgGlowBottom)"/>
|
||||
|
||||
<!-- Subtle grain (very light) -->
|
||||
<g opacity="0.08">
|
||||
<path d="M0 84 C160 120 340 40 520 86 C700 132 820 210 1200 160" stroke="#FFFFFF" stroke-opacity="0.10" stroke-width="2"/>
|
||||
<path d="M0 188 C220 240 360 160 560 204 C760 248 900 330 1200 300" stroke="#FFFFFF" stroke-opacity="0.08" stroke-width="2"/>
|
||||
<path d="M0 440 C240 380 420 520 620 470 C820 420 960 500 1200 460" stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="2"/>
|
||||
<g opacity="0.22">
|
||||
<circle cx="998" cy="406" r="1.8" fill="#FF7649"/>
|
||||
<circle cx="1036" cy="446" r="1.4" fill="#FF7649"/>
|
||||
<circle cx="1088" cy="492" r="1.2" fill="#FF7649"/>
|
||||
<circle cx="1116" cy="540" r="1.1" fill="#FF7649"/>
|
||||
<circle cx="964" cy="502" r="1.3" fill="#FF7649"/>
|
||||
<circle cx="880" cy="528" r="1.3" fill="#FF7649"/>
|
||||
<circle cx="716" cy="452" r="1.1" fill="#FF7649"/>
|
||||
<circle cx="622" cy="396" r="1.4" fill="#FF7649"/>
|
||||
<circle cx="188" cy="558" r="1.3" fill="#FF7649"/>
|
||||
<circle cx="152" cy="580" r="1.1" fill="#FF7649"/>
|
||||
<circle cx="92" cy="594" r="1.4" fill="#FF7649"/>
|
||||
</g>
|
||||
|
||||
<!-- Right mark -->
|
||||
<g opacity="0.22" filter="url(#softBlur)">
|
||||
<image href="clawd-mark.png" x="740" y="70" width="560" height="560" preserveAspectRatio="xMidYMid meet"/>
|
||||
</g>
|
||||
<path d="M870 146C990 166 1082 230 1142 328" stroke="#AA3D2B" stroke-opacity="0.16" stroke-width="2"/>
|
||||
<path d="M926 190C1036 234 1108 306 1168 420" stroke="#AA3D2B" stroke-opacity="0.12" stroke-width="2"/>
|
||||
<path d="M1044 374H1200" stroke="#B74A36" stroke-opacity="0.28" stroke-width="2"/>
|
||||
<path d="M24 522H164" stroke="#B74A36" stroke-opacity="0.22" stroke-width="2"/>
|
||||
|
||||
<!-- Content card -->
|
||||
<g filter="url(#cardShadow)">
|
||||
<rect x="72" y="96" width="640" height="438" rx="34" fill="#201B18" fill-opacity="0.92" stroke="url(#stroke)"/>
|
||||
</g>
|
||||
<rect x="42" y="108" width="1092" height="430" rx="42" fill="url(#frameGlow)"/>
|
||||
<rect x="42.75" y="108.75" width="1090.5" height="428.5" rx="41.25" stroke="url(#frameStroke)" stroke-width="1.5"/>
|
||||
<rect x="113" y="148" width="292" height="292" rx="38" fill="#09090C"/>
|
||||
<rect x="113.75" y="148.75" width="290.5" height="290.5" rx="37.25" stroke="url(#logoStroke)" stroke-width="1.5"/>
|
||||
|
||||
<!-- Tiny mark -->
|
||||
<image href="clawd-mark.png" x="108" y="134" width="46" height="46" preserveAspectRatio="xMidYMid meet"/>
|
||||
<ellipse cx="1118" cy="180" rx="86" ry="42" fill="#FF6532" fill-opacity="0.18" filter="url(#glowBlur)"/>
|
||||
<ellipse cx="1018" cy="328" rx="208" ry="164" fill="#8A2218" fill-opacity="0.12" filter="url(#softBlur)"/>
|
||||
<ellipse cx="96" cy="532" rx="48" ry="10" fill="#FF5E35" fill-opacity="0.24" filter="url(#softBlur)"/>
|
||||
<ellipse cx="572" cy="494" rx="302" ry="12" fill="#FF5E35" fill-opacity="0.12" filter="url(#softBlur)"/>
|
||||
|
||||
<!-- Pill -->
|
||||
<g>
|
||||
<rect x="166" y="136" width="304" height="42" rx="21" fill="url(#pill)" stroke="#E86A47" stroke-opacity="0.28"/>
|
||||
<text x="186" y="163"
|
||||
fill="#F6EFE4"
|
||||
font-size="18"
|
||||
font-weight="600"
|
||||
font-family="Bricolage Grotesque, Manrope, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif"
|
||||
opacity="0.92">lobster-light. agent-right.</text>
|
||||
</g>
|
||||
<image href="clawd-logo.png" x="124" y="158" width="270" height="270" preserveAspectRatio="xMidYMid meet"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="112" y="265"
|
||||
fill="#F6EFE4"
|
||||
font-size="92"
|
||||
font-weight="700"
|
||||
font-family="Bricolage Grotesque, Manrope, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif">ClawHub</text>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<text x="114" y="332"
|
||||
fill="#C6B8A8"
|
||||
font-size="23"
|
||||
font-weight="500"
|
||||
font-family="Manrope, Bricolage Grotesque, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif">
|
||||
a fast skill registry for agents, with vector search.
|
||||
<text x="500" y="256" fill="#F8EEE8" font-size="88" font-weight="900" letter-spacing="-4.2" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">ClawHub.ai</text>
|
||||
<text x="500" y="338" fill="#F8EEE8" font-size="42" font-weight="800" letter-spacing="-1.2" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">Equip. Install. <tspan fill="#FF6236">Build.</tspan></text>
|
||||
<text x="500" y="390" fill="#E1D4CF" font-size="23" font-weight="500" letter-spacing="-0.15" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">
|
||||
<tspan x="500" dy="0">Developer tools and agent skills</tspan>
|
||||
<tspan x="500" dy="28">for your next project.</tspan>
|
||||
</text>
|
||||
|
||||
<!-- Accent line + hint -->
|
||||
<rect x="114" y="372" width="110" height="6" rx="3" fill="#E86A47"/>
|
||||
<text x="114" y="430"
|
||||
fill="#F6EFE4"
|
||||
font-size="20"
|
||||
font-weight="600"
|
||||
opacity="0.90"
|
||||
font-family="IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace">clawhub.ai</text>
|
||||
<g>
|
||||
<rect x="148" y="432" width="924" height="104" rx="31" fill="#11090D"/>
|
||||
<rect x="148.75" y="432.75" width="922.5" height="102.5" rx="30.25" stroke="url(#searchStroke)" stroke-width="1.5"/>
|
||||
<circle cx="220" cy="484" r="19" stroke="#FFF9F3" stroke-width="5"/>
|
||||
<line x1="233" y1="497" x2="249" y2="513" stroke="#FFF9F3" stroke-width="5" stroke-linecap="round"/>
|
||||
<text x="272" y="495" fill="#DCD0CB" font-size="31" font-weight="600" letter-spacing="-0.4" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">What are you looking for?</text>
|
||||
<rect x="824" y="450" width="258" height="66" rx="21" fill="url(#buttonFill)"/>
|
||||
<text x="953" y="494" text-anchor="middle" fill="#FFF8F1" font-size="28" font-weight="800" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">Search tools</text>
|
||||
</g>
|
||||
|
||||
<g>
|
||||
<rect x="292" y="574" width="214" height="44" rx="22" fill="#1B1317" stroke="#3A292C"/>
|
||||
<text x="399" y="603" text-anchor="middle" fill="#EEE3DF" font-size="18" font-weight="500" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">self-improving</text>
|
||||
<rect x="530" y="574" width="248" height="44" rx="22" fill="#1B1317" stroke="#3A292C"/>
|
||||
<text x="654" y="603" text-anchor="middle" fill="#EEE3DF" font-size="18" font-weight="500" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">GitHub integration</text>
|
||||
<rect x="802" y="574" width="180" height="44" rx="22" fill="#1B1317" stroke="#3A292C"/>
|
||||
<text x="892" y="603" text-anchor="middle" fill="#EEE3DF" font-size="18" font-weight="500" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">dashboard</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 6.6 KiB |
@@ -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",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderWithInlineCode } from "../routes/about";
|
||||
|
||||
function renderToContainer(text: string) {
|
||||
const { container } = render(<p>{renderWithInlineCode(text)}</p>);
|
||||
return container.querySelector("p")!;
|
||||
}
|
||||
|
||||
describe("renderWithInlineCode", () => {
|
||||
it("returns plain text unchanged when no backticks present", () => {
|
||||
const el = renderToContainer("No code here.");
|
||||
expect(el.textContent).toBe("No code here.");
|
||||
expect(el.querySelectorAll("code")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("wraps backtick-delimited text in <code> elements", () => {
|
||||
const el = renderToContainer("Run `curl | sh` to install.");
|
||||
const codes = el.querySelectorAll("code");
|
||||
expect(codes).toHaveLength(1);
|
||||
expect(codes[0].textContent).toBe("curl | sh");
|
||||
expect(codes[0].className).toBe("about-inline-code");
|
||||
expect(el.textContent).toBe("Run curl | sh to install.");
|
||||
});
|
||||
|
||||
it("handles multiple code spans in a single string", () => {
|
||||
const el = renderToContainer(
|
||||
"Use `curl | sh` or `npx @latest` for setup."
|
||||
);
|
||||
const codes = el.querySelectorAll("code");
|
||||
expect(codes).toHaveLength(2);
|
||||
expect(codes[0].textContent).toBe("curl | sh");
|
||||
expect(codes[1].textContent).toBe("npx @latest");
|
||||
});
|
||||
|
||||
it("handles empty input string", () => {
|
||||
const el = renderToContainer("");
|
||||
expect(el.textContent).toBe("");
|
||||
expect(el.querySelectorAll("code")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles string that is only a code span", () => {
|
||||
const el = renderToContainer("`only-code`");
|
||||
const codes = el.querySelectorAll("code");
|
||||
expect(codes).toHaveLength(1);
|
||||
expect(codes[0].textContent).toBe("only-code");
|
||||
expect(el.textContent).toBe("only-code");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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} />,
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/* @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();
|
||||
expect(img?.getAttribute("src")).toBe("screenshot.png");
|
||||
expect(img?.getAttribute("alt")).toBe("Demo screenshot");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,94 +1,104 @@
|
||||
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 { 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.
|
||||
const baseRehype = [rehypeRaw, [rehypeSanitize, schema]] as const;
|
||||
|
||||
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 }),
|
||||
);
|
||||
}
|
||||
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(() => {
|
||||
if (!highlight) return;
|
||||
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" })],
|
||||
loadHighlighter()
|
||||
.then((h) => {
|
||||
if (!cancelled) setHighlighter(h);
|
||||
})
|
||||
.then((highlighted) => {
|
||||
if (!cancelled) {
|
||||
setHtml(autolinkURLs(highlighted));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Shiki failed to load — keep the sync render
|
||||
});
|
||||
} catch {
|
||||
// Parse failed — clear
|
||||
setHtml("");
|
||||
}
|
||||
|
||||
.catch(() => {
|
||||
// Shiki failed to initialize — keep plain rendering.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [children, highlight]);
|
||||
}, [highlight]);
|
||||
|
||||
const rehypePlugins = useMemo(() => {
|
||||
if (highlight && highlighter) {
|
||||
return [
|
||||
...baseRehype,
|
||||
[rehypeShikiFromHighlighter, highlighter, { theme: SHIKI_THEME }] as const,
|
||||
];
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ type SizeWarning = {
|
||||
};
|
||||
|
||||
const EMPTY_DIFF_TEXT = "";
|
||||
const MOBILE_DIFF_BREAKPOINT = 860;
|
||||
const MOBILE_DIFF_BREAKPOINT = 768;
|
||||
|
||||
function getDefaultViewMode() {
|
||||
if (typeof window === "undefined") return "split";
|
||||
@@ -402,7 +402,6 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
|
||||
) : (
|
||||
<ClientOnly fallback={<div className="diff-empty">Preparing diff…</div>}>
|
||||
<DiffEditor
|
||||
key={`diff-${viewMode}`}
|
||||
className={`diff-monaco diff-monaco-${viewMode}`}
|
||||
original={leftText}
|
||||
modified={rightText}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
|
Before Width: | Height: | Size: 19 KiB |
@@ -10,13 +10,15 @@ import Header from "../components/Header";
|
||||
import { getSiteDescription, getSiteMode, getSiteName, getSiteUrlForMode } from "../lib/site";
|
||||
import appCss from "../styles.css?url";
|
||||
|
||||
const OG_IMAGE_VERSION = "20260420-12";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => {
|
||||
const mode = getSiteMode();
|
||||
const siteName = getSiteName(mode);
|
||||
const siteDescription = getSiteDescription(mode);
|
||||
const siteUrl = getSiteUrlForMode(mode);
|
||||
const ogImage = `${siteUrl}/og.png`;
|
||||
const ogImage = `${siteUrl}/og.png?v=${OG_IMAGE_VERSION}`;
|
||||
|
||||
return {
|
||||
meta: [
|
||||
@@ -97,11 +99,6 @@ export const Route = createRootRoute({
|
||||
href: "/favicon.ico",
|
||||
type: "image/x-icon",
|
||||
},
|
||||
{
|
||||
rel: "icon",
|
||||
href: "/logo.svg",
|
||||
type: "image/svg+xml",
|
||||
},
|
||||
{
|
||||
rel: "apple-touch-icon",
|
||||
href: "/logo192.png",
|
||||
@@ -122,6 +119,11 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var d=document.documentElement,s='clawhub-theme-selection',k='clawhub-theme',n='clawhub-theme-name',l='clawdhub-theme';var sel;try{var raw=localStorage.getItem(s);if(raw){sel=JSON.parse(raw)}}catch(e){}if(!sel){var m=localStorage.getItem(k),t=localStorage.getItem(n);if(m||t){sel={theme:t||'claw',mode:m||'system'}}else{var lg=localStorage.getItem(l);if(lg){var map={dark:'dark',light:'light',system:'system',defaultTheme:'dark',docsTheme:'light',lightTheme:'dark',landingTheme:'dark',newTheme:'dark',openknot:'dark',fieldmanual:'dark',clawdash:'light'};sel={theme:'claw',mode:map[lg]||'system'}}}}if(!sel)sel={theme:'claw',mode:'system'};var themes=['claw'],modes=['system','light','dark'];if(themes.indexOf(sel.theme)<0)sel.theme='claw';if(modes.indexOf(sel.mode)<0)sel.mode='system';var resolved=sel.mode==='system'?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):sel.mode;d.dataset.theme=resolved;d.dataset.themeResolved=resolved;d.dataset.themeMode=sel.mode;d.dataset.themeFamily=sel.theme;if(resolved==='dark')d.classList.add('dark');else d.classList.remove('dark')}catch(e){}})()`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<AppProviders>
|
||||
|
||||
@@ -1,41 +1,72 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
Banknote,
|
||||
Drama,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ImageOff,
|
||||
ShieldOff,
|
||||
UserX,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { getSiteMode, getSiteName, getSiteUrlForMode } from '../lib/site';
|
||||
|
||||
const prohibitedCategories = [
|
||||
export function renderWithInlineCode(text: string): (string | JSX.Element)[] {
|
||||
const parts = text.split(/(`[^`]+`)/g);
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('`') && part.endsWith('`')) {
|
||||
return (
|
||||
<code key={i} className="about-inline-code">
|
||||
{part.slice(1, -1)}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
const prohibitedCategories: { title: string; icon: LucideIcon; examples: string }[] = [
|
||||
{
|
||||
title: 'Bypass and unauthorized access',
|
||||
icon: ShieldOff,
|
||||
examples:
|
||||
'Auth bypass, account takeover, CAPTCHA bypass, Cloudflare or anti-bot evasion, rate-limit bypass, reusable session theft, live call or agent takeover.',
|
||||
},
|
||||
{
|
||||
title: 'Platform abuse and ban evasion',
|
||||
icon: UserX,
|
||||
examples:
|
||||
'Stealth accounts after bans, account warming/farming, fake engagement, multi-account automation, spam posting, marketplace or social automation built to avoid detection.',
|
||||
},
|
||||
{
|
||||
title: 'Fraud and deception',
|
||||
icon: Banknote,
|
||||
examples:
|
||||
'Fake certificates, fake invoices, deceptive payment flows, fake social proof, scam outreach, or synthetic-identity workflows built to create accounts for fraud.',
|
||||
},
|
||||
{
|
||||
title: 'Privacy-invasive surveillance',
|
||||
icon: Eye,
|
||||
examples:
|
||||
'Mass contact scraping for spam, doxxing, stalking, covert monitoring, biometric / face-matching workflows without clear consent, or buying, publishing, downloading, or operationalizing leaked data or breach dumps.',
|
||||
},
|
||||
{
|
||||
title: 'Non-consensual impersonation',
|
||||
icon: Drama,
|
||||
examples:
|
||||
'Face swap, digital twins, cloned influencers, fake personas, or other identity manipulation used to impersonate or mislead.',
|
||||
},
|
||||
{
|
||||
title: 'Explicit sexual content',
|
||||
icon: ImageOff,
|
||||
examples:
|
||||
'NSFW image, video, or text generation, especially wrappers around third-party APIs with safety checks disabled.',
|
||||
},
|
||||
{
|
||||
title: 'Hidden or misleading execution',
|
||||
icon: EyeOff,
|
||||
examples:
|
||||
'Obfuscated install commands, `curl | sh`, undeclared secret requirements, undeclared private-key use, or remote `npx @latest` execution without reviewability.',
|
||||
},
|
||||
@@ -115,15 +146,20 @@ function AboutPage() {
|
||||
<h2 className="home-section-title">Rejection Categories</h2>
|
||||
</div>
|
||||
<div className="about-grid">
|
||||
{prohibitedCategories.map((category) => (
|
||||
<article
|
||||
key={category.title}
|
||||
className="about-rule-card"
|
||||
>
|
||||
<h2>{category.title}</h2>
|
||||
<p>{category.examples}</p>
|
||||
</article>
|
||||
))}
|
||||
{prohibitedCategories.map((category) => {
|
||||
const Icon = category.icon;
|
||||
return (
|
||||
<article key={category.title} className="about-rule-card">
|
||||
<div className="about-rule-card-header">
|
||||
<div className="about-rule-card-icon">
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<h3>{category.title}</h3>
|
||||
</div>
|
||||
<p>{renderWithInlineCode(category.examples)}</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useAction, useQuery } from "convex/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
Download,
|
||||
Layers,
|
||||
Package,
|
||||
Search,
|
||||
Shield,
|
||||
Star,
|
||||
Users,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { SoulCard } from "../components/SoulCard";
|
||||
@@ -30,6 +28,13 @@ function Home() {
|
||||
return mode === "souls" ? <OnlyCrabsHome /> : <SkillsHome />;
|
||||
}
|
||||
|
||||
// ═══ Slot machine word pool (13 words = 1/13 jackpot odds) ═══
|
||||
const SLOT_WORDS = [
|
||||
"Equip", "Install", "Unleash", "Ship", "Build",
|
||||
"Create", "Deploy", "Launch", "Hack", "Scale",
|
||||
"Forge", "Craft", "Wield",
|
||||
];
|
||||
|
||||
function SkillsHome() {
|
||||
type SkillPageEntry = {
|
||||
skill: PublicSkill;
|
||||
@@ -99,8 +104,263 @@ function SkillsHome() {
|
||||
// Build carousel cards from highlighted data
|
||||
const carouselCards = highlighted.length > 0 ? highlighted.slice(0, 6) : [];
|
||||
|
||||
// ═══ SLOT MACHINE EASTER EGG ═══
|
||||
const HACK_INDEX = SLOT_WORDS.indexOf("Hack");
|
||||
const clickTimesRef = useRef<number[]>([]);
|
||||
const [slotState, setSlotState] = useState<
|
||||
| null
|
||||
| { phase: "spinning" }
|
||||
| { phase: "stopped"; results: [number, number, number]; won: boolean; isHackJackpot: boolean }
|
||||
>(null);
|
||||
const slotTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const [slotReelOffsets, setSlotReelOffsets] = useState<[number, number, number]>([0, 0, 0]);
|
||||
const [stoppedReels, setStoppedReels] = useState<Set<number>>(new Set());
|
||||
const confettiRef = useRef<HTMLCanvasElement>(null);
|
||||
const spinIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const cooldownUntilRef = useRef<number>(0);
|
||||
|
||||
// Clean up timers/intervals if the component unmounts mid-spin
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const t of slotTimersRef.current) clearTimeout(t);
|
||||
if (spinIntervalRef.current) clearInterval(spinIntervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const triggerSlots = useCallback(() => {
|
||||
// Clean up any previous timers
|
||||
for (const t of slotTimersRef.current) clearTimeout(t);
|
||||
slotTimersRef.current = [];
|
||||
if (spinIntervalRef.current) clearInterval(spinIntervalRef.current);
|
||||
|
||||
setSlotState({ phase: "spinning" });
|
||||
setStoppedReels(new Set());
|
||||
|
||||
// Controlled odds: ~1/25 any jackpot, ~1/100 Hack jackpot
|
||||
let r0: number, r1: number, r2: number;
|
||||
const isJackpot = Math.random() < 1 / 25;
|
||||
if (isJackpot) {
|
||||
// 25% of jackpots are Hack (1/25 × 1/4 = 1/100 overall)
|
||||
const isHack = Math.random() < 0.25;
|
||||
if (isHack) {
|
||||
r0 = HACK_INDEX;
|
||||
} else {
|
||||
// Pick any word except Hack
|
||||
let idx = Math.floor(Math.random() * (SLOT_WORDS.length - 1));
|
||||
if (idx >= HACK_INDEX) idx++;
|
||||
r0 = idx;
|
||||
}
|
||||
r1 = r0;
|
||||
r2 = r0;
|
||||
} else {
|
||||
// Normal spin — re-roll if accidental triple match
|
||||
do {
|
||||
r0 = Math.floor(Math.random() * SLOT_WORDS.length);
|
||||
r1 = Math.floor(Math.random() * SLOT_WORDS.length);
|
||||
r2 = Math.floor(Math.random() * SLOT_WORDS.length);
|
||||
} while (r0 === r1 && r1 === r2);
|
||||
}
|
||||
const results: [number, number, number] = [r0, r1, r2];
|
||||
const landed = new Set<number>();
|
||||
|
||||
// Animate fast offset cycling — only cycle reels that haven't landed
|
||||
let frame = 0;
|
||||
const spinInterval = setInterval(() => {
|
||||
frame++;
|
||||
setSlotReelOffsets((prev) => [
|
||||
landed.has(0) ? prev[0] : (frame * 3) % SLOT_WORDS.length,
|
||||
landed.has(1) ? prev[1] : (frame * 5 + 4) % SLOT_WORDS.length,
|
||||
landed.has(2) ? prev[2] : (frame * 7 + 9) % SLOT_WORDS.length,
|
||||
]);
|
||||
}, 60);
|
||||
spinIntervalRef.current = spinInterval;
|
||||
|
||||
// Stop reels sequentially with a satisfying stagger
|
||||
const stopReel = (reelIdx: 0 | 1 | 2, delay: number) => {
|
||||
const t = setTimeout(() => {
|
||||
landed.add(reelIdx);
|
||||
setStoppedReels((prev) => new Set(prev).add(reelIdx));
|
||||
setSlotReelOffsets((prev) => {
|
||||
const next = [...prev] as [number, number, number];
|
||||
next[reelIdx] = results[reelIdx];
|
||||
return next;
|
||||
});
|
||||
}, delay);
|
||||
slotTimersRef.current.push(t);
|
||||
};
|
||||
|
||||
stopReel(0, 1200);
|
||||
stopReel(1, 1800);
|
||||
|
||||
const tFinal = setTimeout(() => {
|
||||
clearInterval(spinInterval);
|
||||
spinIntervalRef.current = null;
|
||||
landed.add(2);
|
||||
setStoppedReels(new Set([0, 1, 2]));
|
||||
setSlotReelOffsets(results);
|
||||
const won = r0 === r1 && r1 === r2;
|
||||
const isHackJackpot = won && r0 === HACK_INDEX;
|
||||
setSlotState({ phase: "stopped", results, won, isHackJackpot });
|
||||
if (won) {
|
||||
fireConfetti(isHackJackpot);
|
||||
}
|
||||
// Cooldown: 18s after win, 3s after loss
|
||||
const displayTime = won ? 10000 : 2400;
|
||||
const cooldownTime = won ? 18000 : 3000;
|
||||
cooldownUntilRef.current = Date.now() + cooldownTime;
|
||||
const tReset = setTimeout(() => {
|
||||
setSlotState(null);
|
||||
setStoppedReels(new Set());
|
||||
}, displayTime);
|
||||
slotTimersRef.current.push(tReset);
|
||||
}, 2400);
|
||||
slotTimersRef.current.push(tFinal);
|
||||
}, []);
|
||||
|
||||
const handleLabelClick = useCallback(() => {
|
||||
const now = Date.now();
|
||||
// Respect cooldown period
|
||||
if (now < cooldownUntilRef.current) return;
|
||||
clickTimesRef.current.push(now);
|
||||
// Keep only last 3 clicks
|
||||
if (clickTimesRef.current.length > 3) {
|
||||
clickTimesRef.current = clickTimesRef.current.slice(-3);
|
||||
}
|
||||
if (clickTimesRef.current.length === 3) {
|
||||
const first = clickTimesRef.current[0];
|
||||
const last = clickTimesRef.current[2];
|
||||
if (last - first < 800 && !slotState) {
|
||||
clickTimesRef.current = [];
|
||||
triggerSlots();
|
||||
}
|
||||
}
|
||||
}, [slotState, triggerSlots]);
|
||||
|
||||
const fireConfetti = (isHackJackpot: boolean) => {
|
||||
const canvas = confettiRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
canvas.style.display = "block";
|
||||
|
||||
const STANDARD_COLORS = [
|
||||
"#d4453a", "#ff6b6b", "#ffd93d", "#6bcb77",
|
||||
"#4d96ff", "#ff6f91", "#845ec2", "#ffc75f",
|
||||
];
|
||||
const OCEAN_COLORS = [
|
||||
"#0ea5e9", "#06b6d4", "#14b8a6", "#22d3ee",
|
||||
"#38bdf8", "#67e8f9", "#a5f3fc", "#2dd4bf",
|
||||
"#d4453a", "#ff6b6b",
|
||||
];
|
||||
const colors = isHackJackpot ? OCEAN_COLORS : STANDARD_COLORS;
|
||||
|
||||
type Particle = {
|
||||
x: number; y: number; vx: number; vy: number;
|
||||
w: number; h: number; color: string; rot: number; vr: number;
|
||||
life: number; shape: "rect" | "bubble" | "claw";
|
||||
};
|
||||
const particles: Particle[] = [];
|
||||
|
||||
const count = isHackJackpot ? 200 : 150;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const isBubble = isHackJackpot && Math.random() < 0.35;
|
||||
const isClaw = isHackJackpot && !isBubble && Math.random() < 0.2;
|
||||
particles.push({
|
||||
x: canvas.width / 2 + (Math.random() - 0.5) * 300,
|
||||
y: canvas.height * 0.35,
|
||||
vx: (Math.random() - 0.5) * 18,
|
||||
vy: isHackJackpot
|
||||
? -Math.random() * 14 - 2 + (isBubble ? -4 : 0)
|
||||
: -Math.random() * 16 - 4,
|
||||
w: isBubble ? Math.random() * 8 + 4 : Math.random() * 10 + 4,
|
||||
h: isBubble ? 0 : Math.random() * 6 + 3,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
rot: Math.random() * Math.PI * 2,
|
||||
vr: (Math.random() - 0.5) * 0.3,
|
||||
life: isHackJackpot ? 1.3 : 1,
|
||||
shape: isClaw ? "claw" : isBubble ? "bubble" : "rect",
|
||||
});
|
||||
}
|
||||
|
||||
const drawClaw = (ctx: 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();
|
||||
};
|
||||
|
||||
let raf: number;
|
||||
const draw = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
let alive = false;
|
||||
for (const p of particles) {
|
||||
if (p.life <= 0) continue;
|
||||
alive = true;
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy += p.shape === "bubble" ? 0.15 : 0.4;
|
||||
p.vx *= 0.99;
|
||||
p.rot += p.vr;
|
||||
p.life -= isHackJackpot ? 0.005 : 0.008;
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot);
|
||||
ctx.globalAlpha = Math.max(0, Math.min(1, p.life));
|
||||
ctx.fillStyle = p.color;
|
||||
|
||||
if (p.shape === "bubble") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, p.w, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = p.color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.globalAlpha *= 0.7;
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha *= 0.15;
|
||||
ctx.fill();
|
||||
} else if (p.shape === "claw") {
|
||||
drawClaw(ctx, p.w);
|
||||
} else {
|
||||
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
if (alive) {
|
||||
raf = requestAnimationFrame(draw);
|
||||
} else {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
canvas.style.display = "none";
|
||||
}
|
||||
};
|
||||
raf = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
const renderSlotReel = (reelIdx: 0 | 1 | 2) => {
|
||||
const offset = slotReelOffsets[reelIdx];
|
||||
const word = SLOT_WORDS[offset];
|
||||
const isReelSpinning = slotState !== null && !stoppedReels.has(reelIdx);
|
||||
return (
|
||||
<span className={`home-v2-slot-reel ${isReelSpinning ? "spinning" : ""}`}>
|
||||
<span className="home-v2-slot-word">{word}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="home-v2-main">
|
||||
{/* Confetti canvas for slot machine wins */}
|
||||
<canvas
|
||||
ref={confettiRef}
|
||||
className="home-v2-confetti"
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
{/* ═══ HERO ═══ */}
|
||||
<section className="home-v2-hero">
|
||||
<div className="home-v2-hero-bg">
|
||||
@@ -111,25 +371,59 @@ function SkillsHome() {
|
||||
<div className="home-v2-ring home-v2-ring-3" />
|
||||
</div>
|
||||
|
||||
<div className="home-v2-hero-label">BUILT BY THE COMMUNITY.</div>
|
||||
<div
|
||||
className={`home-v2-hero-label ${slotState ? "home-v2-hero-label-active" : ""}`}
|
||||
onClick={handleLabelClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleLabelClick(); }}
|
||||
>
|
||||
BUILT BY THE COMMUNITY.
|
||||
</div>
|
||||
|
||||
<h1 className="home-v2-headline">
|
||||
<span className="home-v2-headline-inner">
|
||||
<span className="home-v2-action-word">Equip</span>
|
||||
<span className="home-v2-sep" />
|
||||
<span className="home-v2-action-word">Install</span>
|
||||
<span className="home-v2-sep" />
|
||||
<span className="home-v2-cycle-wrap">
|
||||
<span className="home-v2-cycle-track">
|
||||
<span className="home-v2-cycle-word">Unleash.</span>
|
||||
<span className="home-v2-cycle-word">Ship.</span>
|
||||
<span className="home-v2-cycle-word">Build.</span>
|
||||
<span className="home-v2-cycle-word">Create.</span>
|
||||
<span className="home-v2-cycle-word">Unleash.</span>
|
||||
{slotState ? (
|
||||
<h1 className={`home-v2-headline home-v2-headline-slots${
|
||||
slotState.phase === "stopped" && slotState.won
|
||||
? slotState.isHackJackpot
|
||||
? " home-v2-headline-jackpot home-v2-headline-hack"
|
||||
: " home-v2-headline-jackpot"
|
||||
: ""
|
||||
}`}>
|
||||
{slotState.phase === "stopped" && slotState.isHackJackpot && (
|
||||
<img
|
||||
src="/clawd-mark.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="home-v2-hack-lobster"
|
||||
/>
|
||||
)}
|
||||
<span className="home-v2-headline-inner">
|
||||
{renderSlotReel(0)}
|
||||
<span className="home-v2-sep" />
|
||||
{renderSlotReel(1)}
|
||||
<span className="home-v2-sep" />
|
||||
{renderSlotReel(2)}
|
||||
</span>
|
||||
</h1>
|
||||
) : (
|
||||
<h1 className="home-v2-headline">
|
||||
<span className="home-v2-headline-inner">
|
||||
<span className="home-v2-action-word">Equip</span>
|
||||
<span className="home-v2-sep" />
|
||||
<span className="home-v2-action-word">Install</span>
|
||||
<span className="home-v2-sep" />
|
||||
<span className="home-v2-cycle-wrap">
|
||||
<span className="home-v2-cycle-track">
|
||||
<span className="home-v2-cycle-word">Unleash.</span>
|
||||
<span className="home-v2-cycle-word">Ship.</span>
|
||||
<span className="home-v2-cycle-word">Build.</span>
|
||||
<span className="home-v2-cycle-word">Create.</span>
|
||||
<span className="home-v2-cycle-word">Unleash.</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
</h1>
|
||||
)}
|
||||
|
||||
<p className="home-v2-sub">Tools built by thousands, ready in one search.</p>
|
||||
|
||||
@@ -144,7 +438,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>
|
||||
@@ -156,28 +451,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>
|
||||
@@ -206,9 +501,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}
|
||||
@@ -218,9 +510,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>
|
||||
@@ -249,9 +539,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}
|
||||
@@ -261,9 +548,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>
|
||||
@@ -306,7 +591,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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
|
||||
@@ -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} />,
|
||||
};
|
||||
|
||||
|
||||