Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fec5db744c | ||
|
|
cb75011244 | ||
|
|
18b23886ab | ||
|
|
beb50f4b49 | ||
|
|
623a414b9b | ||
|
|
91c7e44031 | ||
|
|
850fc9c78e | ||
|
|
f5ec0882d5 | ||
|
|
b2e751c5fc | ||
|
|
4c8032bb97 | ||
|
|
2c435a3d7b | ||
|
|
1f990cf66d |
|
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"
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1200" y2="630" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#14110F"/>
|
||||
<stop offset="0.55" stop-color="#1A1512"/>
|
||||
<stop offset="1" stop-color="#14110F"/>
|
||||
</linearGradient>
|
||||
|
||||
<radialGradient id="glowOrange" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(260 60) rotate(120) scale(520 420)">
|
||||
<stop stop-color="#E86A47" stop-opacity="0.55"/>
|
||||
<stop offset="1" stop-color="#E86A47" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
|
||||
<radialGradient id="glowSea" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(1050 120) rotate(140) scale(520 420)">
|
||||
<stop stop-color="#4AD8B7" stop-opacity="0.35"/>
|
||||
<stop offset="1" stop-color="#4AD8B7" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
|
||||
<filter id="softBlur" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation="24"/>
|
||||
</filter>
|
||||
|
||||
<filter id="cardShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="26" flood-color="#000000" flood-opacity="0.6"/>
|
||||
</filter>
|
||||
|
||||
<linearGradient id="pill" x1="0" y1="0" x2="360" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E86A47" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="#E86A47" stop-opacity="0.08"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="stroke" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop stop-color="#FFFFFF" stop-opacity="0.16"/>
|
||||
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.06"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||
<circle cx="260" cy="60" r="520" fill="url(#glowOrange)" filter="url(#softBlur)"/>
|
||||
<circle cx="1050" cy="120" r="520" fill="url(#glowSea)" filter="url(#softBlur)"/>
|
||||
|
||||
<!-- Subtle grain (very light) -->
|
||||
<g opacity="0.08">
|
||||
<path d="M0 84 C160 120 340 40 520 86 C700 132 820 210 1200 160" stroke="#FFFFFF" stroke-opacity="0.10" stroke-width="2"/>
|
||||
<path d="M0 188 C220 240 360 160 560 204 C760 248 900 330 1200 300" stroke="#FFFFFF" stroke-opacity="0.08" stroke-width="2"/>
|
||||
<path d="M0 440 C240 380 420 520 620 470 C820 420 960 500 1200 460" stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="2"/>
|
||||
</g>
|
||||
|
||||
<!-- Right mark -->
|
||||
<g opacity="0.22" filter="url(#softBlur)">
|
||||
<image href="clawd-mark.png" x="740" y="70" width="560" height="560" preserveAspectRatio="xMidYMid meet"/>
|
||||
</g>
|
||||
|
||||
<!-- Content card -->
|
||||
<g filter="url(#cardShadow)">
|
||||
<rect x="72" y="96" width="640" height="438" rx="34" fill="#201B18" fill-opacity="0.92" stroke="url(#stroke)"/>
|
||||
</g>
|
||||
|
||||
<!-- Tiny mark -->
|
||||
<image href="clawd-mark.png" x="108" y="134" width="46" height="46" preserveAspectRatio="xMidYMid meet"/>
|
||||
|
||||
<!-- Pill -->
|
||||
<g>
|
||||
<rect x="166" y="136" width="304" height="42" rx="21" fill="url(#pill)" stroke="#E86A47" stroke-opacity="0.28"/>
|
||||
<text x="186" y="163"
|
||||
fill="#F6EFE4"
|
||||
font-size="18"
|
||||
font-weight="600"
|
||||
font-family="Bricolage Grotesque, Manrope, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif"
|
||||
opacity="0.92">lobster-light. agent-right.</text>
|
||||
</g>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="112" y="265"
|
||||
fill="#F6EFE4"
|
||||
font-size="92"
|
||||
font-weight="700"
|
||||
font-family="Bricolage Grotesque, Manrope, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif">ClawHub</text>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<text x="114" y="332"
|
||||
fill="#C6B8A8"
|
||||
font-size="23"
|
||||
font-weight="500"
|
||||
font-family="Manrope, Bricolage Grotesque, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif">
|
||||
a fast skill registry for agents, with vector search.
|
||||
</text>
|
||||
|
||||
<!-- Accent line + hint -->
|
||||
<rect x="114" y="372" width="110" height="6" rx="3" fill="#E86A47"/>
|
||||
<text x="114" y="430"
|
||||
fill="#F6EFE4"
|
||||
font-size="20"
|
||||
font-weight="600"
|
||||
opacity="0.90"
|
||||
font-family="IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace">clawhub.ai</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.1 KiB |
@@ -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");
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 19 KiB |
@@ -97,11 +97,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",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
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,
|
||||
@@ -30,6 +30,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 +106,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 +373,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>
|
||||
|
||||
|
||||
@@ -710,7 +710,7 @@ code {
|
||||
|
||||
.navbar-top {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(148px, auto) minmax(320px, 1fr) minmax(320px, auto);
|
||||
grid-template-columns: minmax(148px, auto) minmax(200px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
@@ -1041,7 +1041,7 @@ code {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
min-width: 276px;
|
||||
min-width: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -7366,15 +7366,47 @@ html.theme-transition::view-transition-new(theme) {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: color-mix(in srgb, var(--surface) 88%, transparent);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
transition: border-color 0.25s, box-shadow 0.3s, transform 0.25s, background 0.25s;
|
||||
}
|
||||
|
||||
.about-rule-card:hover {
|
||||
border-color: var(--border-ui-hover);
|
||||
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.08);
|
||||
transform: translateY(-3px);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.2),
|
||||
0 0 0 1px rgba(220, 38, 38, 0.06);
|
||||
background: color-mix(in srgb, var(--surface) 95%, transparent);
|
||||
}
|
||||
|
||||
.about-rule-card h2 {
|
||||
.about-rule-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.about-rule-card-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent-subtle);
|
||||
border: 1.5px solid rgba(220, 38, 38, 0.25);
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.about-rule-card-icon svg {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.about-rule-card:hover .about-rule-card-icon {
|
||||
background: rgba(220, 38, 38, 0.18);
|
||||
box-shadow: 0 0 14px rgba(220, 38, 38, 0.08);
|
||||
}
|
||||
|
||||
.about-rule-card :is(h2, h3) {
|
||||
margin: 0;
|
||||
font-size: 1.22rem;
|
||||
font-family: var(--font-display);
|
||||
@@ -7387,12 +7419,20 @@ html.theme-transition::view-transition-new(theme) {
|
||||
.about-rule-card p {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
color: var(--ink-soft);
|
||||
color: color-mix(in srgb, var(--ink) 75%, transparent);
|
||||
line-height: 1.72;
|
||||
font-size: 0.98rem;
|
||||
max-width: 30ch;
|
||||
}
|
||||
|
||||
.about-inline-code {
|
||||
background: var(--surface-muted);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--r-xs);
|
||||
font-size: 0.88em;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.about-pattern {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
@@ -7433,6 +7473,11 @@ html.theme-transition::view-transition-new(theme) {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.about-grid,
|
||||
.about-panel-categories .about-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 22px;
|
||||
}
|
||||
.about-patterns {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -7459,6 +7504,11 @@ html.theme-transition::view-transition-new(theme) {
|
||||
grid-column: span 5;
|
||||
}
|
||||
|
||||
.about-grid,
|
||||
.about-panel-categories .about-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
.about-patterns {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
@@ -7659,7 +7709,7 @@ html.theme-transition::view-transition-new(theme) {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 80px 48px 56px;
|
||||
padding: 64px 48px 48px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.home-v2-hero > *:not(.home-v2-hero-bg) {
|
||||
@@ -7725,6 +7775,10 @@ html.theme-transition::view-transition-new(theme) {
|
||||
text-transform: uppercase;
|
||||
color: var(--hv2-text-tertiary);
|
||||
margin-bottom: 20px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
transition: color 0.2s, text-shadow 0.3s;
|
||||
}
|
||||
.home-v2-headline {
|
||||
font-family: 'Inter', sans-serif;
|
||||
@@ -7797,17 +7851,126 @@ html.theme-transition::view-transition-new(theme) {
|
||||
100% { transform: translateY(-80%); }
|
||||
}
|
||||
|
||||
/* ═══ SLOT MACHINE EASTER EGG ═══ */
|
||||
.home-v2-hero-label:hover {
|
||||
color: var(--hv2-accent);
|
||||
}
|
||||
.home-v2-hero-label-active {
|
||||
color: var(--hv2-accent) !important;
|
||||
text-shadow: 0 0 12px rgba(212, 69, 58, 0.4);
|
||||
animation: home-v2-labelPulse 0.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes home-v2-labelPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.home-v2-headline-slots {
|
||||
min-height: 1.15em;
|
||||
position: relative;
|
||||
}
|
||||
.home-v2-slot-reel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 180px;
|
||||
text-align: center;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 800;
|
||||
color: var(--hv2-accent);
|
||||
position: relative;
|
||||
}
|
||||
.home-v2-slot-reel.spinning .home-v2-slot-word {
|
||||
animation: home-v2-slotBlur 0.12s steps(1) infinite;
|
||||
}
|
||||
.home-v2-slot-reel:not(.spinning) .home-v2-slot-word {
|
||||
animation: home-v2-slotLand 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
@keyframes home-v2-slotBlur {
|
||||
0% { filter: blur(0px); opacity: 1; }
|
||||
50% { filter: blur(1px); opacity: 0.7; }
|
||||
100% { filter: blur(0px); opacity: 1; }
|
||||
}
|
||||
@keyframes home-v2-slotLand {
|
||||
0% { transform: translateY(-8px) scale(1.1); opacity: 0.5; }
|
||||
60% { transform: translateY(2px) scale(0.98); }
|
||||
100% { transform: translateY(0) scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.home-v2-headline-jackpot {
|
||||
animation: home-v2-jackpot 0.5s ease-out;
|
||||
}
|
||||
@keyframes home-v2-jackpot {
|
||||
0% { transform: scale(1); }
|
||||
30% { transform: scale(1.08); }
|
||||
60% { transform: scale(0.97); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
.home-v2-headline-jackpot .home-v2-slot-word {
|
||||
color: #ffd93d !important;
|
||||
text-shadow:
|
||||
0 0 20px rgba(255, 217, 61, 0.6),
|
||||
0 0 40px rgba(255, 217, 61, 0.3);
|
||||
}
|
||||
|
||||
/* ═══ HACK × 3 — Lobster / Aquatic Jackpot ═══ */
|
||||
.home-v2-headline-hack .home-v2-slot-word {
|
||||
color: #22d3ee !important;
|
||||
text-shadow:
|
||||
0 0 24px rgba(34, 211, 238, 0.6),
|
||||
0 0 48px rgba(6, 182, 212, 0.3),
|
||||
0 2px 8px rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
.home-v2-headline-hack .home-v2-sep {
|
||||
border-color: #22d3ee;
|
||||
opacity: 0.8;
|
||||
box-shadow: 0 0 8px rgba(34, 211, 238, 0.5);
|
||||
}
|
||||
.home-v2-hack-lobster {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 0 40px rgba(34, 211, 238, 0.5)) drop-shadow(0 0 80px rgba(6, 182, 212, 0.25));
|
||||
animation: home-v2-lobsterReveal 1.2s 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
z-index: -1;
|
||||
}
|
||||
@keyframes home-v2-lobsterReveal {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0) rotate(-30deg);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.18;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1) rotate(0deg);
|
||||
opacity: 0.12;
|
||||
}
|
||||
}
|
||||
|
||||
.home-v2-confetti {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-v2-sub {
|
||||
color: var(--hv2-text-tertiary);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 36px;
|
||||
max-width: 480px;
|
||||
max-width: 580px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.home-v2-sub-clear {
|
||||
color: var(--hv2-text-secondary);
|
||||
max-width: 720px;
|
||||
max-width: 820px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.home-v2-motto {
|
||||
@@ -8596,7 +8759,7 @@ html.theme-transition::view-transition-new(theme) {
|
||||
|
||||
/* ═══ HOME V2 — Wider search + even dot spacing ═══ */
|
||||
.home-v2-search-container {
|
||||
max-width: 780px;
|
||||
max-width: 920px;
|
||||
}
|
||||
.home-v2-search-bar {
|
||||
padding: 8px 8px 8px 24px;
|
||||
|
||||