Compare commits

..
Author SHA1 Message Date
Val Alexander 6ebcf67ab2 Merge branch 'staging' into okcode/featured-card-hover-home-link 2026-04-18 14:37:18 -05:00
Val AlexanderandClaude Opus 4.6 82a3cd3590 chore: remove unused footer divider element
The site-footer-divider was already hidden via CSS (display: none) on
home-v2 pages. Remove the element entirely since it serves no purpose.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 14:31:50 -05:00
Val AlexanderandClaude Opus 4.6 6c9339260d fix: address review feedback — mobile brand, category grid, hover drama
- Keep brand name visible on mobile (remove display:none for
  .brand-name-responsive at ≤639px) so the home link is always
  discoverable. Add TODO comment on the commented-out logo block.
- Add .about-panel-categories .about-grid to the ≤640px media query
  so the category grid correctly collapses to single-column on mobile.
- Bump carousel card hover to translateY(-3px) with 0 6px 24px shadow
  for a slightly more dramatic lift — still within the 12px top / 48px
  bottom track padding so nothing clips.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 14:19:20 -05:00
Val AlexanderandClaude Opus 4.6 f33d995113 fix: reduce carousel card hover effect and increase track padding
The carousel cards were getting clipped by the parent overflow:hidden
container. Reduce the hover transform from translateY(-4px) scale(1.01)
to translateY(-2px) and shrink box-shadow spread across all theme
variants. Increase carousel track top padding from 4px to 12px to
accommodate the upward shift without cutoff.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 14:07:50 -05:00
Val AlexanderandClaude Opus 4.6 95c3ae361b fix: remove extra footer padding and ensure full-width nav/footer for boxed layout
Zero out the outer .site-footer padding and set background to transparent
on home-v2 pages so the app-shell background bleeds through edge-to-edge.
Remove the redundant light-mode footer background override (app-shell
background already covers it). Nav and footer now visually span full
viewport width while .home-v2-main content stays boxed at --page-max.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 14:03:15 -05:00
Val AlexanderandClaude Opus 4.6 527b77ee19 fix: impose max page width on home page using --page-max (1536px)
Constrain .home-v2-main to max-width: var(--page-max) and center it
with margin-inline: auto. Extend the home page background color to the
full viewport via .app-shell:has(.home-v2-main) for both light and dark
themes so the background bleeds edge-to-edge beyond the content column.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 13:55:02 -05:00
Val AlexanderandClaude Opus 4.6 2478c441ba fix: hide logo, use ClawHub as home link, and clean up rejection categories grid
Comment out the brand logo image for now, rename "Immediate rejection
categories" to "Rejection Categories", remove the featured card variant,
and switch to an auto-fill grid so cards spread evenly at full width.
Add overflow: visible on the categories panel to prevent hover shadow
clipping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 13:46:50 -05:00
36 changed files with 608 additions and 1763 deletions
-20
View File
@@ -95,23 +95,3 @@ When working on Convex code, **always read `convex/_generated/ai/guidelines.md`
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
<!-- convex-ai-end -->
## Stat Field Migration Rules
The `skills` table maintains two parallel sets of stat fields as part of an in-progress field migration:
| Legacy (nested, `@deprecated`) | Top-level (source of truth, indexable) |
|---|---|
| `stats.downloads` | `statsDownloads` |
| `stats.stars` | `statsStars` |
| `stats.installsCurrent` | `statsInstallsCurrent` |
| `stats.installsAllTime` | `statsInstallsAllTime` |
**Rules:**
- **Always use `readCanonicalStat(skill, field)` (`convex/lib/skillStats.ts`) to read** any of the four migrated fields. It prefers the top-level field and falls back to the nested field for pre-migration documents. Never access `skill.stats.downloads` / `.stars` / `.installsCurrent` / `.installsAllTime` directly.
- **Always use `applySkillStatDeltas()` to write** stat deltas. It writes both the top-level and nested fields in the same patch to keep them in sync.
- **Both sets of fields must be written together** in any patch that touches stat values (see the return shape of `applySkillStatDeltas`).
- **Nested-only reads are acceptable only for** `stats.comments` and `stats.versions` — no top-level field exists for these yet.
- The four legacy nested fields are marked `@deprecated` in `statsValidator` (schema.ts). Any IDE access to `skill.stats.downloads` etc. will show a strikethrough warning — treat this as a signal to use `readCanonicalStat()` instead.
- When adding new stat fields, follow the same dual-write pattern and add a cursor-based backfill mutation (see `backfillSkillStatFieldsInternal` for an example).
-1
View File
@@ -5,7 +5,6 @@
### Changed
- Search: add CJK tokenization support (Chinese/Japanese/Korean) with Intl.Segmenter plus fallback behavior to improve skill query matching (#1596) (thanks @pq-dong).
- Stats: centralize migrated skill stat fallback reads through `readCanonicalStat()` and add schema/agent guardrails to discourage direct legacy nested-field access (#1709) (thanks @momothemage).
### Fixes
+1 -1
View File
@@ -10,7 +10,7 @@
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
</p>
ClawHub is the **public skill registry for OpenClaw**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
ClawHub is the **public skill registry for Clawdbot**: 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.
+11 -27
View File
@@ -10,34 +10,18 @@ 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 = readCanonicalStat(skill, "downloads");
const currentStars = readCanonicalStat(skill, "stars");
const currentInstallsCurrent = readCanonicalStat(skill, "installsCurrent");
const currentInstallsAllTime = readCanonicalStat(skill, "installsAllTime");
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 currentComments = skill.stats.comments;
const nextDownloads = Math.max(0, currentDownloads + (deltas.downloads ?? 0));
+7 -7
View File
@@ -2146,9 +2146,9 @@ export const insertReleaseInternal = internalMutation({
const nextIsOfficial = nextChannel === "official";
const nextOwnerPublisherId = stringifyOptionalId(args.ownerPublisherId ?? null);
const nextOwnerUserId = stringifyId(args.ownerUserId);
const 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>";
const nextName = args.name;
const nextRuntimeId = args.runtimeId ?? null;
const nextVersion = args.version;
if (existing) {
const existingIsLegacyPersonalPackage =
!existing.ownerPublisherId &&
@@ -2171,7 +2171,7 @@ export const insertReleaseInternal = internalMutation({
}
if (existing && existing.family !== args.family) {
throw new ConvexError(
`Package "${nextNameLabel}" already exists as a ${existing.family}; family changes are not allowed`,
`Package "${nextName}" 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 "${nextNameLabel}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
`Package "${nextName}" 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 "${nextRuntimeIdLabel}" is already claimed by another package`);
throw new ConvexError(`Plugin id "${nextRuntimeId}" 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 ${nextVersionLabel} already exists`);
if (releaseExists) throw new ConvexError(`Version ${nextVersion} already exists`);
}
const priorReleases = existing
? await ctx.db
-12
View File
@@ -95,22 +95,10 @@ const badgesValidator = v.optional(
}),
);
/**
* Nested stat fields on the `skills` document.
*
* The four migrated fields below are kept for backward compatibility only.
* Always use the top-level fields (`statsDownloads`, `statsStars`,
* `statsInstallsCurrent`, `statsInstallsAllTime`) as the source of truth,
* and use `readCanonicalStat()` / `applySkillStatDeltas()` to read/write them.
*/
const statsValidator = v.object({
/** @deprecated Use top-level `statsDownloads` instead. */
downloads: v.number(),
/** @deprecated Use top-level `statsInstallsCurrent` instead. */
installsCurrent: v.optional(v.number()),
/** @deprecated Use top-level `statsInstallsAllTime` instead. */
installsAllTime: v.optional(v.number()),
/** @deprecated Use top-level `statsStars` instead. */
stars: v.number(),
versions: v.number(),
comments: v.number(),
@@ -1,182 +0,0 @@
# Logo Replacement Design
Date: 2026-04-21
Topic: Comprehensive logo replacement using the provided lobster artwork
## Summary
Replace every current application logo surface with the user-provided lobster artwork while preserving the existing UI layout and copy. This includes in-app logo images, favicon and install icon assets, and manifest/head wiring. The existing wide social preview image `public/og.png` remains unchanged. Instead, `public/og-logo.png` is included in the replacement asset pack as a standalone logo export and is not wired into site metadata.
## Goals
- Replace all current logo imagery with the provided lobster art.
- Preserve existing layout structure in header, mobile navigation, and hero content.
- Provide dedicated asset files for browser, install, and app surfaces rather than relying on one large source image everywhere.
- Keep runtime references stable where possible by replacing existing filenames in place.
- Improve browser/device logo behavior by adding standard favicon and touch icon variants.
## Non-Goals
- No header, navigation, or hero layout redesign.
- No typography or copy changes to the `ClawHub` wordmark text.
- No change to the existing social preview card asset `public/og.png`.
- No full vector redraw of the lobster artwork from scratch.
## Scope
### In Scope
- Replace:
- `public/clawd-logo.png`
- `public/clawd-mark.png`
- `public/logo192.png`
- `public/logo512.png`
- `public/favicon.ico`
- Add or update:
- `public/favicon-16x16.png`
- `public/favicon-32x32.png`
- `public/apple-touch-icon.png`
- `public/logo.jpg`
- `public/logo.svg`
- `public/og-logo.png`
- Update runtime/browser metadata:
- root document link tags in `src/routes/__root.tsx`
- `public/manifest.json`
### Out of Scope
- `public/og.png`
- Any route-level social metadata currently using `og.png`
- Any non-logo artwork or unrelated illustration assets
## Current State
- The app currently references `public/clawd-logo.png` in the desktop and mobile header.
- The homepage hero references `public/clawd-mark.png`.
- The root document exposes `/favicon.ico`, `/logo192.png`, and `/manifest.json`.
- The web app manifest references `favicon.ico`, `logo192.png`, and `logo512.png`.
- The site-wide OG metadata still references `og.png`.
## Recommended Approach
Use the provided lobster image as the master artwork and derive a small asset pack tailored to each output surface.
Why this approach:
- It satisfies the request to replace the logo everywhere it appears.
- It avoids visual degradation from blindly reusing one oversized raster in tiny favicon contexts.
- It minimizes application code changes by preserving the established filenames used by the UI.
## Asset Plan
### Master Asset
Create one high-resolution square source derived from the attached lobster artwork. This will be the basis for all exported formats.
### Replacement Assets
- `clawd-logo.png`
- High-resolution square PNG used by header/mobile brand image references.
- `clawd-mark.png`
- High-resolution square PNG used by hero/logo-only surfaces.
- `logo192.png`
- 192×192 install icon.
- `logo512.png`
- 512×512 install icon.
- `favicon.ico`
- Multi-size favicon generated from the same master for browser tab use.
- `favicon-16x16.png`
- Explicit raster favicon for browsers that prefer PNG.
- `favicon-32x32.png`
- Explicit raster favicon for higher-density tab/bookmark use.
- `apple-touch-icon.png`
- 180×180 touch icon for iOS home screen usage.
- `logo.jpg`
- Flattened JPEG export for contexts where a non-transparent logo file is useful.
- `logo.svg`
- SVG wrapper asset that embeds the logo image in an SVG container so an SVG logo file exists for downstream usage without falsely claiming the art is natively vector.
- `og-logo.png`
- Logo-focused branded raster asset retained separately from the existing wide social card `og.png`.
## Runtime Wiring
### Application UI
- Keep existing JSX references to `clawd-logo.png` and `clawd-mark.png` unless a clearer dedicated asset path becomes necessary.
- Do not replace image elements with text or SVG components.
### Root Head Tags
Update `src/routes/__root.tsx` to use dedicated icon assets:
- `rel="icon"` should include PNG favicon variants in addition to the ICO.
- `rel="apple-touch-icon"` should point to `apple-touch-icon.png`.
- `rel="manifest"` remains `manifest.json`.
- OG/Twitter metadata remains wired to `og.png` and is not changed.
### Web App Manifest
Update `public/manifest.json` so install surfaces reference the replacement icon assets. Keep the manifest conservative and omit maskable-specific `purpose` values for this change.
## Data Flow
1. Start from the provided lobster artwork.
2. Export optimized raster variants for each target size.
3. Replace or add files in `public/`.
4. Update root document links and manifest entries.
5. Build the app and verify the logo surfaces still render without layout regressions.
## Error Handling And Risks
### Small-Size Legibility
Risk: the artwork is detailed and may lose clarity at favicon sizes.
Mitigation:
- Generate dedicated 16×16 and 32×32 outputs instead of relying only on browser downscaling.
- Prefer the ICO plus PNG favicon set to maximize compatibility.
### Raster-As-Vector Expectations
Risk: a pure SVG redraw would be time-consuming and subjective.
Mitigation:
- Provide `logo.svg` as an SVG container asset, while using raster files for browser/runtime surfaces that need visual fidelity.
### Unintended Social Preview Changes
Risk: a broad asset refresh accidentally changes OG behavior.
Mitigation:
- Explicitly leave `og.png` and its metadata references untouched.
- Treat `og-logo.png` as a separate logo asset only.
## Testing And Verification
- Confirm the generated files exist in `public/` with expected dimensions.
- Run the production build to ensure asset references still resolve.
- Spot-check the following surfaces:
- desktop header brand image
- mobile navigation brand image
- homepage hero lobster image
- browser favicon and touch icon wiring
- manifest icon references
- Verify that `og.png` remains unchanged and the site metadata still references it.
## Implementation Notes
- Use minimal code churn: replace files in place where existing paths are already correct.
- Add new icon files only where they improve browser/device handling.
- Keep the change tightly scoped to branding assets and metadata.
## Acceptance Criteria
- Every current application logo surface displays the provided lobster artwork instead of the previous brand image.
- Favicon, touch icon, and install icons resolve to replacement assets.
- Header/mobile/hero layout remains unchanged.
- `og.png` is not modified.
- `og-logo.png` exists as part of the updated asset pack.
- The app builds successfully after the change.
-135
View File
@@ -1,135 +0,0 @@
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);
});
-8
View File
@@ -29,13 +29,5 @@ export default defineConfig({
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "mobile-chrome",
use: { ...devices["Pixel 7"] },
},
{
name: "mobile-safari",
use: { ...devices["iPhone 14"] },
},
],
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 792 B

After

Width:  |  Height:  |  Size: 4.3 KiB

+45
View File
@@ -0,0 +1,45 @@
<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>

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 169 KiB

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

Before

Width:  |  Height:  |  Size: 133 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

After

Width:  |  Height:  |  Size: 292 KiB

+80 -84
View File
@@ -1,102 +1,98 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="bgGlowRight" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(1048 328) rotate(180) scale(358 260)">
<stop stop-color="#7B1F18" stop-opacity="0.58"/>
<stop offset="0.45" stop-color="#431210" stop-opacity="0.26"/>
<stop offset="1" stop-color="#030305" stop-opacity="0"/>
<linearGradient id="bg" x1="0" y1="0" x2="1200" y2="630" gradientUnits="userSpaceOnUse">
<stop stop-color="#14110F"/>
<stop offset="0.55" stop-color="#1A1512"/>
<stop offset="1" stop-color="#14110F"/>
</linearGradient>
<radialGradient id="glowOrange" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(260 60) rotate(120) scale(520 420)">
<stop stop-color="#E86A47" stop-opacity="0.55"/>
<stop offset="1" stop-color="#E86A47" stop-opacity="0"/>
</radialGradient>
<radialGradient id="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 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>
<linearGradient id="frameStroke" x1="40" y1="106" x2="1146" y2="530" gradientUnits="userSpaceOnUse">
<stop stop-color="#6D3437" stop-opacity="0.8"/>
<stop offset="0.55" stop-color="#DF5D35" stop-opacity="0.36"/>
<stop offset="1" stop-color="#FF6D39" stop-opacity="0.9"/>
</linearGradient>
<linearGradient id="frameGlow" x1="164" y1="164" x2="1114" y2="476" gradientUnits="userSpaceOnUse">
<stop stop-color="#130D10"/>
<stop offset="0.5" stop-color="#170B0E"/>
<stop offset="1" stop-color="#261011"/>
</linearGradient>
<linearGradient id="logoStroke" x1="112" y1="140" x2="398" y2="430" gradientUnits="userSpaceOnUse">
<stop stop-color="#4C2A28" stop-opacity="0.55"/>
<stop offset="1" stop-color="#E05831" stop-opacity="0.28"/>
</linearGradient>
<linearGradient id="searchStroke" x1="146" y1="480" x2="1068" y2="480" gradientUnits="userSpaceOnUse">
<stop stop-color="#7F342A" stop-opacity="0.65"/>
<stop offset="1" stop-color="#FF6F37" stop-opacity="0.85"/>
</linearGradient>
<linearGradient id="buttonFill" x1="838" y1="445" x2="1084" y2="510" gradientUnits="userSpaceOnUse">
<stop stop-color="#D55335"/>
<stop offset="1" stop-color="#EB6A3E"/>
</linearGradient>
<filter id="softBlur" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="20"/>
<filter id="softBlur" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="24"/>
</filter>
<filter id="glowBlur" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="10"/>
<filter id="cardShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="18" stdDeviation="26" flood-color="#000000" flood-opacity="0.6"/>
</filter>
<linearGradient id="pill" x1="0" y1="0" x2="360" y2="0" gradientUnits="userSpaceOnUse">
<stop stop-color="#E86A47" stop-opacity="0.22"/>
<stop offset="1" stop-color="#E86A47" stop-opacity="0.08"/>
</linearGradient>
<linearGradient id="stroke" x1="0" y1="0" x2="0" y2="1">
<stop stop-color="#FFFFFF" stop-opacity="0.16"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.06"/>
</linearGradient>
</defs>
<rect width="1200" height="630" fill="#030305"/>
<rect width="1200" height="630" fill="url(#bgGlowRight)"/>
<rect width="1200" height="630" fill="url(#bgGlowBottom)"/>
<!-- 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)"/>
<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"/>
<!-- 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>
<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"/>
<!-- 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>
<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"/>
<!-- 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>
<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)"/>
<!-- Tiny mark -->
<image href="clawd-mark.png" x="108" y="134" width="46" height="46" preserveAspectRatio="xMidYMid meet"/>
<image href="clawd-logo.png" x="124" y="158" width="270" height="270" preserveAspectRatio="xMidYMid meet"/>
<!-- Pill -->
<g>
<rect x="166" y="136" width="304" height="42" rx="21" fill="url(#pill)" stroke="#E86A47" stroke-opacity="0.28"/>
<text x="186" y="163"
fill="#F6EFE4"
font-size="18"
font-weight="600"
font-family="Bricolage Grotesque, Manrope, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif"
opacity="0.92">lobster-light. agent-right.</text>
</g>
<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>
<!-- 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>
<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>
<!-- Accent line + hint -->
<rect x="114" y="372" width="110" height="6" rx="3" fill="#E86A47"/>
<text x="114" y="430"
fill="#F6EFE4"
font-size="20"
font-weight="600"
opacity="0.90"
font-family="IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace">clawhub.ai</text>
</svg>

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

+11 -38
View File
@@ -1,39 +1,6 @@
import { copyFile, mkdir, stat } from "node:fs/promises";
import { copyFile, mkdir } 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"),
@@ -45,7 +12,7 @@ const copies = [
],
},
{
source: resvgWasmSource,
source: path.resolve("node_modules/@resvg/resvg-wasm/index_bg.wasm"),
targets: [
path.resolve(".output/server/node_modules/@resvg/resvg-wasm/index_bg.wasm"),
path.resolve(
@@ -54,7 +21,9 @@ const copies = [
],
},
{
source: bricolage800Source,
source: path.resolve(
"node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2",
),
targets: [
path.resolve(
".output/server/node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2",
@@ -65,7 +34,9 @@ const copies = [
],
},
{
source: bricolage500Source,
source: path.resolve(
"node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2",
),
targets: [
path.resolve(
".output/server/node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2",
@@ -76,7 +47,9 @@ const copies = [
],
},
{
source: ibmPlex500Source,
source: path.resolve(
"node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2",
),
targets: [
path.resolve(
".output/server/node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2",
-51
View File
@@ -1,51 +0,0 @@
/* @vitest-environment jsdom */
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { renderWithInlineCode } from "../routes/about";
function renderToContainer(text: string) {
const { container } = render(<p>{renderWithInlineCode(text)}</p>);
return container.querySelector("p")!;
}
describe("renderWithInlineCode", () => {
it("returns plain text unchanged when no backticks present", () => {
const el = renderToContainer("No code here.");
expect(el.textContent).toBe("No code here.");
expect(el.querySelectorAll("code")).toHaveLength(0);
});
it("wraps backtick-delimited text in <code> elements", () => {
const el = renderToContainer("Run `curl | sh` to install.");
const codes = el.querySelectorAll("code");
expect(codes).toHaveLength(1);
expect(codes[0].textContent).toBe("curl | sh");
expect(codes[0].className).toBe("about-inline-code");
expect(el.textContent).toBe("Run curl | sh to install.");
});
it("handles multiple code spans in a single string", () => {
const el = renderToContainer(
"Use `curl | sh` or `npx @latest` for setup."
);
const codes = el.querySelectorAll("code");
expect(codes).toHaveLength(2);
expect(codes[0].textContent).toBe("curl | sh");
expect(codes[1].textContent).toBe("npx @latest");
});
it("handles empty input string", () => {
const el = renderToContainer("");
expect(el.textContent).toBe("");
expect(el.querySelectorAll("code")).toHaveLength(0);
});
it("handles string that is only a code span", () => {
const el = renderToContainer("`only-code`");
const codes = el.querySelectorAll("code");
expect(codes).toHaveLength(1);
expect(codes[0].textContent).toBe("only-code");
expect(el.textContent).toBe("only-code");
});
});
+94 -130
View File
@@ -9,185 +9,149 @@ 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("Home")).toHaveLength(1);
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Users")).toHaveLength(2);
});
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Users")).toHaveLength(2);
});
it("shows Home above Skills in the mobile menu", () => {
siteModeMock.mockReturnValue("skills");
it("routes soul-mode header searches to the souls browse page", () => {
siteModeMock.mockReturnValue("souls");
navigateMock.mockReset();
render(<Header />);
render(<Header />);
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
target: { value: "angler" },
});
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
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,
},
});
});
expect(navigateMock).toHaveBeenCalledWith({
to: "/souls",
search: {
q: "angler",
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
},
});
});
});
+2 -2
View File
@@ -4,9 +4,9 @@ import {
MessageSquare,
Package,
Plug,
RefreshCw,
Shield,
Wrench,
Zap,
} from "lucide-react";
import type { SkillCategory } from "../lib/categories";
@@ -39,7 +39,7 @@ const CATEGORY_ICONS: Record<string, React.ReactNode> = {
"dev-tools": <Wrench size={15} />,
data: <Database size={15} />,
security: <Shield size={15} />,
automation: <RefreshCw size={15} />,
automation: <Zap size={15} />,
other: <Package size={15} />,
};
+4 -20
View File
@@ -129,29 +129,12 @@ export default function Header() {
</button>
<SheetContent side="left" className="mobile-nav-sheet">
<SheetHeader className="pr-10">
<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>
<SheetTitle>{siteName}</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">
@@ -219,9 +202,10 @@ export default function Header() {
search={{ q: undefined, highlighted: undefined, search: undefined }}
className="brand"
>
<span className="brand-mark">
{/* TODO: Re-introduce logo once new asset is ready */}
{/* <span className="brand-mark">
<img src="/clawd-logo.png" alt="" aria-hidden="true" className="brand-mark-image" />
</span>
</span> */}
<span className="brand-name brand-name-responsive">{siteName}</span>
</Link>
+1 -1
View File
@@ -143,7 +143,7 @@ export function SkillCommentsPanel({ skillId, isAuthenticated, me }: SkillCommen
comments.map((entry) => (
<div
key={entry.comment._id}
className="comment-entry flex gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-3"
className="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">
+3 -60
View File
@@ -1,13 +1,10 @@
/* @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,
@@ -21,37 +18,15 @@ 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", {
@@ -90,8 +65,6 @@ describe("SkillDiffCard", () => {
beforeEach(() => {
getFileTextMock.mockReset();
getFileTextMock.mockResolvedValue({ text: "content" });
diffEditorMounts = 0;
diffEditorUnmounts = 0;
});
it("defaults to inline mode on narrow screens", async () => {
@@ -135,34 +108,4 @@ 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);
});
});
+2 -1
View File
@@ -38,7 +38,7 @@ type SizeWarning = {
};
const EMPTY_DIFF_TEXT = "";
const MOBILE_DIFF_BREAKPOINT = 768;
const MOBILE_DIFF_BREAKPOINT = 860;
function getDefaultViewMode() {
if (typeof window === "undefined") return "split";
@@ -402,6 +402,7 @@ 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}
-121
View File
@@ -1,121 +0,0 @@
/* @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();
});
});
+19 -79
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useSyncExternalStore } from "react";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
const PREFERENCES_KEY = "clawhub-preferences";
@@ -66,95 +66,36 @@ 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);
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();
}
};
return () => listeners.delete(listener);
}
function notifyListeners() {
listeners.forEach((listener) => listener());
}
function getStoredPreferences(): UserPreferences {
if (typeof window === "undefined") return defaultPreferences;
try {
const stored = window.localStorage.getItem(PREFERENCES_KEY);
if (!stored) return defaultPreferences;
const parsed = JSON.parse(stored) as Partial<UserPreferences>;
return { ...defaultPreferences, ...parsed };
} catch {
return defaultPreferences;
}
}
function savePreferences(prefs: UserPreferences) {
if (typeof window === "undefined") return;
try {
const serialized = JSON.stringify(prefs);
window.localStorage.setItem(PREFERENCES_KEY, serialized);
cachedPreferencesRaw = serialized;
cachedPreferencesSnapshot = prefs;
hasCachedPreferences = true;
window.localStorage.setItem(PREFERENCES_KEY, JSON.stringify(prefs));
notifyListeners();
} catch {
// Storage might be full or disabled
}
notifyListeners();
}
// Server snapshot for SSR
@@ -165,7 +106,7 @@ function getServerSnapshot(): UserPreferences {
export function usePreferences() {
const preferences = useSyncExternalStore(
subscribe,
readStoredPreferences,
getStoredPreferences,
getServerSnapshot
);
@@ -173,13 +114,13 @@ export function usePreferences() {
key: K,
value: UserPreferences[K]
) => {
const current = readStoredPreferences();
const current = getStoredPreferences();
const updated = { ...current, [key]: value };
savePreferences(updated);
}, []);
const updatePreferences = useCallback((updates: Partial<UserPreferences>) => {
const current = readStoredPreferences();
const current = getStoredPreferences();
const updated = { ...current, ...updates };
savePreferences(updated);
}, []);
@@ -222,4 +163,3 @@ export function usePreferences() {
}
export { defaultPreferences };
export { readStoredPreferences as getStoredPreferencesSnapshot };
+12
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

+6 -8
View File
@@ -10,15 +10,13 @@ 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?v=${OG_IMAGE_VERSION}`;
const ogImage = `${siteUrl}/og.png`;
return {
meta: [
@@ -99,6 +97,11 @@ 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",
@@ -119,11 +122,6 @@ 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>
+10 -46
View File
@@ -1,72 +1,41 @@
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';
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 }[] = [
const prohibitedCategories = [
{
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.',
},
@@ -146,20 +115,15 @@ function AboutPage() {
<h2 className="home-section-title">Rejection Categories</h2>
</div>
<div className="about-grid">
{prohibitedCategories.map((category) => {
const Icon = category.icon;
return (
<article key={category.title} className="about-rule-card">
<div className="about-rule-card-header">
<div className="about-rule-card-icon">
<Icon size={18} />
</div>
<h3>{category.title}</h3>
</div>
<p>{renderWithInlineCode(category.examples)}</p>
</article>
);
})}
{prohibitedCategories.map((category) => (
<article
key={category.title}
className="about-rule-card"
>
<h2>{category.title}</h2>
<p>{category.examples}</p>
</article>
))}
</div>
</section>
+38 -323
View File
@@ -1,16 +1,18 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useAction, useQuery } from "convex/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
ArrowRight,
ChevronRight,
Code2,
Download,
Package,
Layers,
Search,
Shield,
Star,
Users,
Zap,
} from "lucide-react";
import { api } from "../../convex/_generated/api";
import { SoulCard } from "../components/SoulCard";
@@ -28,13 +30,6 @@ 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;
@@ -104,263 +99,8 @@ 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">
@@ -371,59 +111,25 @@ function SkillsHome() {
<div className="home-v2-ring home-v2-ring-3" />
</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>
<div className="home-v2-hero-label">BUILT BY THE COMMUNITY.</div>
{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>
<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>
</h1>
)}
</span>
</h1>
<p className="home-v2-sub">Tools built by thousands, ready in one search.</p>
@@ -438,8 +144,7 @@ function SkillsHome() {
/>
<kbd>/</kbd>
<button type="submit" className="home-v2-search-go">
<span className="home-v2-search-go-label">Search</span>{" "}
<ArrowRight size={16} />
Search <ArrowRight size={16} />
</button>
</form>
</div>
@@ -451,28 +156,28 @@ function SkillsHome() {
className="home-v2-suggestion"
onClick={() => handleSuggestion("self-improving agent")}
>
self-improving agent
<Zap size={13} /> self-improving agent
</button>
<button
type="button"
className="home-v2-suggestion"
onClick={() => handleSuggestion("GitHub integration")}
>
GitHub integration
<Code2 size={13} /> GitHub integration
</button>
<button
type="button"
className="home-v2-suggestion"
onClick={() => handleSuggestion("security soul")}
>
security soul
<Shield size={13} /> security soul
</button>
<button
type="button"
className="home-v2-suggestion"
onClick={() => handleSuggestion("dashboard builder")}
>
dashboard builder
<Layers size={13} /> dashboard builder
</button>
</div>
</section>
@@ -501,6 +206,9 @@ 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}
@@ -510,7 +218,9 @@ function SkillsHome() {
</div>
</div>
</div>
<span className="home-v2-c-tag">Skill</span>
<span className="home-v2-c-tag">
<Zap size={11} /> Skill
</span>
<div className="home-v2-c-desc">
{entry.skill.summary || "A fresh skill bundle."}
</div>
@@ -539,6 +249,9 @@ 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}
@@ -548,7 +261,9 @@ function SkillsHome() {
</div>
</div>
</div>
<span className="home-v2-c-tag">Skill</span>
<span className="home-v2-c-tag">
<Zap size={11} /> Skill
</span>
<div className="home-v2-c-desc">
{entry.skill.summary || "A fresh skill bundle."}
</div>
@@ -591,7 +306,7 @@ function SkillsHome() {
className="home-v2-cat-item"
>
<div className="home-v2-cat-icon">
<Package size={20} />
<Zap size={20} />
</div>
<div className="home-v2-cat-text">
<div className="home-v2-cat-name">Skills</div>
+10 -3
View File
@@ -9,7 +9,9 @@ import {
Moon,
RotateCcw,
Settings2,
Sparkles,
Sun,
Zap,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -493,7 +495,8 @@ export function Settings() {
{/* Code & Content Section - Advanced */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)]">
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
<Sparkles size={14} className="text-[color:var(--accent)]" />
Code &amp; Content
</Label>
@@ -530,7 +533,10 @@ export function Settings() {
</SelectTrigger>
<SelectContent>
<SelectItem value="full">
Full
<span className="flex items-center gap-2">
<Zap size={14} />
Full
</span>
</SelectItem>
<SelectItem value="reduced">Reduced</SelectItem>
<SelectItem value="none">None</SelectItem>
@@ -598,7 +604,8 @@ export function Settings() {
{/* Experimental Features - Advanced */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)]">
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
<Sparkles size={14} className="text-[color:var(--gold)]" />
Experimental
</Label>
+2 -2
View File
@@ -8,11 +8,11 @@ import {
MessageSquare,
Package,
Plug,
RefreshCw,
Search,
Shield,
Wrench,
X,
Zap,
} from "lucide-react";
import type { RefObject } from "react";
import { useMemo } from "react";
@@ -64,7 +64,7 @@ const CATEGORY_ICONS: Record<string, React.ReactNode> = {
"dev-tools": <Wrench size={13} />,
data: <Database size={13} />,
security: <Shield size={13} />,
automation: <RefreshCw size={13} />,
automation: <Zap size={13} />,
other: <Package size={13} />,
};
+249 -400
View File
File diff suppressed because it is too large Load Diff