Compare commits

...
Author SHA1 Message Date
Val Alexander 194c22f4dd Add branded mobile nav header
- Show the logo mark in the mobile drawer title
- Tighten mobile suggestion spacing on small screens
- Add test coverage for the branded mobile nav header
2026-04-18 17:50:21 -05:00
Val Alexander a693b945fa Add Home link to mobile header navigation
- Insert a Home entry at the top of the mobile menu
- Update header tests to cover the new menu order
2026-04-18 17:40:22 -05:00
Val Alexander 9bef672541 Merge branch 'okcode/polished-card-icons-logo' 2026-04-18 17:28:51 -05:00
Val Alexander 9551cac37b Merge pull request #1729 from openclaw/okcode/fix-settings-update
Stabilize preferences sync and keep diff editor mounted
2026-04-18 17:25:52 -05:00
Val Alexander eb4138fbb3 fix: harden preferences storage sync 2026-04-18 17:24:31 -05:00
Val Alexanderandgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> 5fbead624b Update src/lib/preferences.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-18 17:17:18 -05:00
Val Alexander 35094177e6 Keep diff editor mounted when switching view mode
- Remove the diff editor remount on inline vs side-by-side toggles
- Add a regression test to verify the editor stays mounted
2026-04-18 17:04:30 -05:00
Val Alexander faa5c9f2b5 Polish icons and brand mark styling
- Simplify home and settings labels by removing redundant icons
- Swap automation icons to refresh glyphs in sidebars and toolbar
- Add subtle border and shadow treatment to the brand mark
2026-04-18 17:03:35 -05:00
Val Alexander c3314c2d01 Stabilize preference snapshots and storage sync
- Cache localStorage reads to avoid redundant snapshot churn
- Sync updates across tabs and add coverage for preference re-renders
2026-04-18 17:03:15 -05:00
Val Alexander 7dfa19157c Merge pull request #1573 from openclaw/staging
chore: merge staging into main
2026-04-18 16:46:00 -05:00
Val AlexanderandClaude Opus 4.6 44acf86ac1 merge: resolve AGENTS.md conflict — keep both convex-ai and stat migration rules
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 16:45:37 -05:00
Val AlexanderandClaude Opus 4.6 a0ebc1b50a style: spread footer columns evenly across full width
Switch footer grid from auto-sized centered columns to equal 1fr
columns that span the full screen width.

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

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

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

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

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

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

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

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

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

* fix: remove white backgrounds from all logo assets

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

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

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

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

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

* style: widen navbar search bar and polish hero section

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-04-18 16:07:48 -05:00
Momoandmomothemage 530e39eedc refactor: extract readCanonicalStat and add structural guards for stat field migration (#1709)
Merged via squash.

Prepared head SHA: e92817f66f
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Reviewed-by: @momothemage
2026-04-17 17:44:32 +08:00
29 changed files with 1238 additions and 615 deletions
+20
View File
@@ -95,3 +95,23 @@ When working on Convex code, **always read `convex/_generated/ai/guidelines.md`
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
<!-- convex-ai-end -->
## Stat Field Migration Rules
The `skills` table maintains two parallel sets of stat fields as part of an in-progress field migration:
| Legacy (nested, `@deprecated`) | Top-level (source of truth, indexable) |
|---|---|
| `stats.downloads` | `statsDownloads` |
| `stats.stars` | `statsStars` |
| `stats.installsCurrent` | `statsInstallsCurrent` |
| `stats.installsAllTime` | `statsInstallsAllTime` |
**Rules:**
- **Always use `readCanonicalStat(skill, field)` (`convex/lib/skillStats.ts`) to read** any of the four migrated fields. It prefers the top-level field and falls back to the nested field for pre-migration documents. Never access `skill.stats.downloads` / `.stars` / `.installsCurrent` / `.installsAllTime` directly.
- **Always use `applySkillStatDeltas()` to write** stat deltas. It writes both the top-level and nested fields in the same patch to keep them in sync.
- **Both sets of fields must be written together** in any patch that touches stat values (see the return shape of `applySkillStatDeltas`).
- **Nested-only reads are acceptable only for** `stats.comments` and `stats.versions` — no top-level field exists for these yet.
- The four legacy nested fields are marked `@deprecated` in `statsValidator` (schema.ts). Any IDE access to `skill.stats.downloads` etc. will show a strikethrough warning — treat this as a signal to use `readCanonicalStat()` instead.
- When adding new stat fields, follow the same dual-write pattern and add a cursor-based backfill mutation (see `backfillSkillStatFieldsInternal` for an example).
+1
View File
@@ -5,6 +5,7 @@
### Changed
- Search: add CJK tokenization support (Chinese/Japanese/Korean) with Intl.Segmenter plus fallback behavior to improve skill query matching (#1596) (thanks @pq-dong).
- Stats: centralize migrated skill stat fallback reads through `readCanonicalStat()` and add schema/agent guardrails to discourage direct legacy nested-field access (#1709) (thanks @momothemage).
### Fixes
+27 -11
View File
@@ -10,18 +10,34 @@ type SkillStatDeltas = {
installsAllTime?: number;
};
/**
* Read the canonical value of a migrated stat field from a skill document.
*
* Top-level fields (`statsDownloads`, etc.) are the source of truth — they are
* indexable and kept up-to-date by the event pipeline. The nested `stats.*`
* fields are only used as a fallback for pre-migration documents where the
* top-level field is still `undefined`.
*
* All code that reads a migrated stat value should go through this function
* rather than accessing `skill.stats.*` directly.
*/
export function readCanonicalStat(
skill: Doc<"skills">,
field: "downloads" | "stars" | "installsCurrent" | "installsAllTime",
): number {
const topLevelKey = `stats${field[0].toUpperCase()}${field.slice(1)}` as
| "statsDownloads"
| "statsStars"
| "statsInstallsCurrent"
| "statsInstallsAllTime";
return typeof skill[topLevelKey] === "number" ? skill[topLevelKey]! : (skill.stats[field] ?? 0);
}
export function applySkillStatDeltas(skill: Doc<"skills">, deltas: SkillStatDeltas) {
const currentDownloads =
typeof skill.statsDownloads === "number" ? skill.statsDownloads : skill.stats.downloads;
const currentStars = typeof skill.statsStars === "number" ? skill.statsStars : skill.stats.stars;
const currentInstallsCurrent =
typeof skill.statsInstallsCurrent === "number"
? skill.statsInstallsCurrent
: (skill.stats.installsCurrent ?? 0);
const currentInstallsAllTime =
typeof skill.statsInstallsAllTime === "number"
? skill.statsInstallsAllTime
: (skill.stats.installsAllTime ?? 0);
const currentDownloads = readCanonicalStat(skill, "downloads");
const currentStars = readCanonicalStat(skill, "stars");
const currentInstallsCurrent = readCanonicalStat(skill, "installsCurrent");
const currentInstallsAllTime = readCanonicalStat(skill, "installsAllTime");
const currentComments = skill.stats.comments;
const nextDownloads = Math.max(0, currentDownloads + (deltas.downloads ?? 0));
+7 -7
View File
@@ -2146,9 +2146,9 @@ export const insertReleaseInternal = internalMutation({
const nextIsOfficial = nextChannel === "official";
const nextOwnerPublisherId = stringifyOptionalId(args.ownerPublisherId ?? null);
const nextOwnerUserId = stringifyId(args.ownerUserId);
const nextName = args.name;
const nextRuntimeId = args.runtimeId ?? null;
const nextVersion = args.version;
const nextNameLabel = typeof args.name === "string" ? args.name : "<unknown>";
const nextRuntimeIdLabel = typeof args.runtimeId === "string" ? args.runtimeId : "<unknown>";
const nextVersionLabel = typeof args.version === "string" ? args.version : "<unknown>";
if (existing) {
const existingIsLegacyPersonalPackage =
!existing.ownerPublisherId &&
@@ -2171,7 +2171,7 @@ export const insertReleaseInternal = internalMutation({
}
if (existing && existing.family !== args.family) {
throw new ConvexError(
`Package "${nextName}" already exists as a ${existing.family}; family changes are not allowed`,
`Package "${nextNameLabel}" already exists as a ${existing.family}; family changes are not allowed`,
);
}
if (
@@ -2182,7 +2182,7 @@ export const insertReleaseInternal = internalMutation({
existing.runtimeId !== args.runtimeId
) {
throw new ConvexError(
`Package "${nextName}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
`Package "${nextNameLabel}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
);
}
if (args.family === "code-plugin" && args.runtimeId) {
@@ -2191,7 +2191,7 @@ export const insertReleaseInternal = internalMutation({
.withIndex("by_runtime_id", (q) => q.eq("runtimeId", args.runtimeId))
.unique();
if (runtimeCollision && runtimeCollision._id !== existing?._id) {
throw new ConvexError(`Plugin id "${nextRuntimeId}" is already claimed by another package`);
throw new ConvexError(`Plugin id "${nextRuntimeIdLabel}" is already claimed by another package`);
}
}
@@ -2228,7 +2228,7 @@ export const insertReleaseInternal = internalMutation({
q.eq("packageId", existing._id).eq("version", args.version),
)
.unique();
if (releaseExists) throw new ConvexError(`Version ${nextVersion} already exists`);
if (releaseExists) throw new ConvexError(`Version ${nextVersionLabel} already exists`);
}
const priorReleases = existing
? await ctx.db
+12
View File
@@ -95,10 +95,22 @@ const badgesValidator = v.optional(
}),
);
/**
* Nested stat fields on the `skills` document.
*
* The four migrated fields below are kept for backward compatibility only.
* Always use the top-level fields (`statsDownloads`, `statsStars`,
* `statsInstallsCurrent`, `statsInstallsAllTime`) as the source of truth,
* and use `readCanonicalStat()` / `applySkillStatDeltas()` to read/write them.
*/
const statsValidator = v.object({
/** @deprecated Use top-level `statsDownloads` instead. */
downloads: v.number(),
/** @deprecated Use top-level `statsInstallsCurrent` instead. */
installsCurrent: v.optional(v.number()),
/** @deprecated Use top-level `statsInstallsAllTime` instead. */
installsAllTime: v.optional(v.number()),
/** @deprecated Use top-level `statsStars` instead. */
stars: v.number(),
versions: v.number(),
comments: v.number(),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 792 B

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

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 285 KiB

+1 -1
View File
@@ -21,5 +21,5 @@
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
"background_color": "#0a0a0a"
}
-98
View File
@@ -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

+38 -11
View File
@@ -1,6 +1,39 @@
import { copyFile, mkdir } from "node:fs/promises";
import { copyFile, mkdir, stat } from "node:fs/promises";
import path from "node:path";
async function resolveExistingPath(candidates: string[]) {
for (const candidate of candidates) {
try {
await stat(candidate);
return candidate;
} catch {
// Try next candidate.
}
}
throw new Error(`Missing required asset. Tried: ${candidates.join(", ")}`);
}
function nodeModuleCandidates(relativePath: string) {
return [
path.resolve(`node_modules/${relativePath}`),
path.resolve(`../../node_modules/${relativePath}`),
];
}
const resvgWasmSource = await resolveExistingPath(
nodeModuleCandidates("@resvg/resvg-wasm/index_bg.wasm"),
);
const bricolage800Source = await resolveExistingPath(
nodeModuleCandidates("@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2"),
);
const bricolage500Source = await resolveExistingPath(
nodeModuleCandidates("@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2"),
);
const ibmPlex500Source = await resolveExistingPath(
nodeModuleCandidates("@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2"),
);
const copies = [
{
source: path.resolve("public/clawd-mark.png"),
@@ -12,7 +45,7 @@ const copies = [
],
},
{
source: path.resolve("node_modules/@resvg/resvg-wasm/index_bg.wasm"),
source: resvgWasmSource,
targets: [
path.resolve(".output/server/node_modules/@resvg/resvg-wasm/index_bg.wasm"),
path.resolve(
@@ -21,9 +54,7 @@ const copies = [
],
},
{
source: path.resolve(
"node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2",
),
source: bricolage800Source,
targets: [
path.resolve(
".output/server/node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2",
@@ -34,9 +65,7 @@ const copies = [
],
},
{
source: path.resolve(
"node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2",
),
source: bricolage500Source,
targets: [
path.resolve(
".output/server/node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2",
@@ -47,9 +76,7 @@ const copies = [
],
},
{
source: path.resolve(
"node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2",
),
source: ibmPlex500Source,
targets: [
path.resolve(
".output/server/node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2",
+51
View File
@@ -0,0 +1,51 @@
/* @vitest-environment jsdom */
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { renderWithInlineCode } from "../routes/about";
function renderToContainer(text: string) {
const { container } = render(<p>{renderWithInlineCode(text)}</p>);
return container.querySelector("p")!;
}
describe("renderWithInlineCode", () => {
it("returns plain text unchanged when no backticks present", () => {
const el = renderToContainer("No code here.");
expect(el.textContent).toBe("No code here.");
expect(el.querySelectorAll("code")).toHaveLength(0);
});
it("wraps backtick-delimited text in <code> elements", () => {
const el = renderToContainer("Run `curl | sh` to install.");
const codes = el.querySelectorAll("code");
expect(codes).toHaveLength(1);
expect(codes[0].textContent).toBe("curl | sh");
expect(codes[0].className).toBe("about-inline-code");
expect(el.textContent).toBe("Run curl | sh to install.");
});
it("handles multiple code spans in a single string", () => {
const el = renderToContainer(
"Use `curl | sh` or `npx @latest` for setup."
);
const codes = el.querySelectorAll("code");
expect(codes).toHaveLength(2);
expect(codes[0].textContent).toBe("curl | sh");
expect(codes[1].textContent).toBe("npx @latest");
});
it("handles empty input string", () => {
const el = renderToContainer("");
expect(el.textContent).toBe("");
expect(el.querySelectorAll("code")).toHaveLength(0);
});
it("handles string that is only a code span", () => {
const el = renderToContainer("`only-code`");
const codes = el.querySelectorAll("code");
expect(codes).toHaveLength(1);
expect(codes[0].textContent).toBe("only-code");
expect(el.textContent).toBe("only-code");
});
});
+130 -94
View File
@@ -9,149 +9,185 @@ const siteModeMock = vi.fn(() => "souls");
const navigateMock = vi.fn();
vi.mock("@tanstack/react-router", () => ({
Link: (props: {
children: ReactNode;
className?: string;
hash?: string;
to?: string;
}) => (
<a href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`} className={props.className}>
{props.children}
</a>
),
useLocation: () => ({ pathname: "/" }),
useNavigate: () => navigateMock,
Link: (props: {
children: ReactNode;
className?: string;
hash?: string;
to?: string;
}) => (
<a
href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`}
className={props.className}
>
{props.children}
</a>
),
useLocation: () => ({ pathname: "/" }),
useNavigate: () => navigateMock,
}));
vi.mock("@convex-dev/auth/react", () => ({
useAuthActions: () => ({
signIn: vi.fn(),
signOut: vi.fn(),
}),
useAuthActions: () => ({
signIn: vi.fn(),
signOut: vi.fn(),
}),
}));
const authStatusMock = vi.fn(() => ({
isAuthenticated: false,
isLoading: false,
me: null,
isAuthenticated: false,
isLoading: false,
me: null,
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => authStatusMock(),
useAuthStatus: () => authStatusMock(),
}));
const setThemeMock = vi.fn();
const setModeMock = vi.fn();
vi.mock("../lib/theme", () => ({
applyTheme: vi.fn(),
THEME_OPTIONS: [
{ value: "claw", label: "Claw", description: "" },
{ value: "hub", label: "Hub", description: "" },
],
useThemeMode: () => ({
theme: "hub",
mode: "system",
setTheme: setThemeMock,
setMode: setModeMock,
}),
applyTheme: vi.fn(),
THEME_OPTIONS: [
{ value: "claw", label: "Claw", description: "" },
{ value: "hub", label: "Hub", description: "" },
],
useThemeMode: () => ({
theme: "hub",
mode: "system",
setTheme: setThemeMock,
setMode: setModeMock,
}),
}));
vi.mock("../lib/theme-transition", () => ({
startThemeTransition: ({
setTheme,
nextTheme,
}: {
setTheme: (value: string) => void;
nextTheme: string;
}) => setTheme(nextTheme),
startThemeTransition: ({
setTheme,
nextTheme,
}: {
setTheme: (value: string) => void;
nextTheme: string;
}) => setTheme(nextTheme),
}));
vi.mock("../lib/useAuthError", () => ({
setAuthError: vi.fn(),
useAuthError: () => ({
error: null,
clear: vi.fn(),
}),
setAuthError: vi.fn(),
useAuthError: () => ({
error: null,
clear: vi.fn(),
}),
}));
vi.mock("../lib/roles", () => ({
isModerator: () => false,
isModerator: () => false,
}));
vi.mock("../lib/site", () => ({
getClawHubSiteUrl: () => "https://clawhub.ai",
getSiteMode: () => siteModeMock(),
getSiteName: () => "OnlyCrabs",
getClawHubSiteUrl: () => "https://clawhub.ai",
getSiteMode: () => siteModeMock(),
getSiteName: () => "OnlyCrabs",
}));
vi.mock("../lib/gravatar", () => ({
gravatarUrl: vi.fn(),
gravatarUrl: vi.fn(),
}));
vi.mock("../components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuSeparator: () => <hr />,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenu: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuItem: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuSeparator: () => <hr />,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock("../components/ui/toggle-group", () => ({
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ToggleGroupItem: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
<button type="button">{children}</button>
),
}));
describe("Header", () => {
it("hides Packages navigation in soul mode on mobile and desktop", () => {
siteModeMock.mockReturnValue("souls");
it("hides Packages navigation in soul mode on mobile and desktop", () => {
siteModeMock.mockReturnValue("souls");
render(<Header />);
render(<Header />);
expect(screen.queryByText("Packages")).toBeNull();
});
expect(screen.queryByText("Packages")).toBeNull();
});
it("renders direct desktop theme family controls and plain Skills tab", () => {
siteModeMock.mockReturnValue("skills");
setThemeMock.mockClear();
setModeMock.mockClear();
it("renders direct desktop theme family controls and plain Skills tab", () => {
siteModeMock.mockReturnValue("skills");
setThemeMock.mockClear();
setModeMock.mockClear();
render(<Header />);
render(<Header />);
expect(screen.getByRole("button", { name: /Cycle theme mode/i })).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);
expect(screen.getAllByText("Users")).toHaveLength(1);
expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy();
expect(
screen.getByRole("button", { name: /Cycle theme mode/i }),
).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);
expect(screen.getAllByText("Users")).toHaveLength(1);
expect(
screen.getByPlaceholderText("Search skills, plugins, users"),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
expect(setModeMock).toHaveBeenCalledWith("light");
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
expect(setModeMock).toHaveBeenCalledWith("light");
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Users")).toHaveLength(2);
});
expect(screen.getAllByText("Home")).toHaveLength(1);
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Users")).toHaveLength(2);
});
it("routes soul-mode header searches to the souls browse page", () => {
siteModeMock.mockReturnValue("souls");
navigateMock.mockReset();
it("shows Home above Skills in the mobile menu", () => {
siteModeMock.mockReturnValue("skills");
render(<Header />);
render(<Header />);
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
target: { value: "angler" },
});
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
expect(navigateMock).toHaveBeenCalledWith({
to: "/souls",
search: {
q: "angler",
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
},
});
});
expect(document.querySelector(".mobile-nav-brand-mark-image")).toBeTruthy();
const labels = Array.from(
document.querySelectorAll(".mobile-nav-section .mobile-nav-link"),
)
.map((element) => element.textContent?.trim())
.filter((label): label is string => Boolean(label));
expect(labels.slice(0, 2)).toEqual(["Home", "Skills"]);
});
it("routes soul-mode header searches to the souls browse page", () => {
siteModeMock.mockReturnValue("souls");
navigateMock.mockReset();
render(<Header />);
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
target: { value: "angler" },
});
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
expect(navigateMock).toHaveBeenCalledWith({
to: "/souls",
search: {
q: "angler",
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
},
});
});
});
+2 -2
View File
@@ -4,9 +4,9 @@ import {
MessageSquare,
Package,
Plug,
RefreshCw,
Shield,
Wrench,
Zap,
} from "lucide-react";
import type { SkillCategory } from "../lib/categories";
@@ -39,7 +39,7 @@ const CATEGORY_ICONS: Record<string, React.ReactNode> = {
"dev-tools": <Wrench size={15} />,
data: <Database size={15} />,
security: <Shield size={15} />,
automation: <Zap size={15} />,
automation: <RefreshCw size={15} />,
other: <Package size={15} />,
};
+20 -4
View File
@@ -129,12 +129,29 @@ export default function Header() {
</button>
<SheetContent side="left" className="mobile-nav-sheet">
<SheetHeader className="pr-10">
<SheetTitle>{siteName}</SheetTitle>
<SheetTitle>
<span className="mobile-nav-brand">
<span className="mobile-nav-brand-mark" aria-hidden="true">
<img
src="/clawd-logo.png"
alt=""
aria-hidden="true"
className="mobile-nav-brand-mark-image"
/>
</span>
<span className="mobile-nav-brand-name">{siteName}</span>
</span>
</SheetTitle>
<SheetDescription>
Browse sections, switch theme, and access account actions.
</SheetDescription>
</SheetHeader>
<div className="mobile-nav-section">
<SheetClose asChild>
<Link to="/" className="mobile-nav-link">
Home
</Link>
</SheetClose>
{isSoulMode ? (
<SheetClose asChild>
<a href={clawHubUrl} className="mobile-nav-link">
@@ -202,10 +219,9 @@ export default function Header() {
search={{ q: undefined, highlighted: undefined, search: undefined }}
className="brand"
>
{/* TODO: Re-introduce logo once new asset is ready */}
{/* <span className="brand-mark">
<span className="brand-mark">
<img src="/clawd-logo.png" alt="" aria-hidden="true" className="brand-mark-image" />
</span> */}
</span>
<span className="brand-name brand-name-responsive">{siteName}</span>
</Link>
+60 -3
View File
@@ -1,10 +1,13 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useEffect } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { SkillDiffCard } from "./SkillDiffCard";
const getFileTextMock = vi.fn();
let diffEditorMounts = 0;
let diffEditorUnmounts = 0;
vi.mock("convex/react", () => ({
useAction: () => getFileTextMock,
@@ -18,15 +21,37 @@ vi.mock("@monaco-editor/react", () => ({
className?: string;
options?: { renderSideBySide?: boolean; useInlineViewWhenSpaceIsLimited?: boolean };
}) => (
<MockDiffEditor
className={className}
options={options}
/>
),
useMonaco: () => null,
}));
function MockDiffEditor({
className,
options,
}: {
className?: string;
options?: { renderSideBySide?: boolean; useInlineViewWhenSpaceIsLimited?: boolean };
}) {
useEffect(() => {
diffEditorMounts += 1;
return () => {
diffEditorUnmounts += 1;
};
}, []);
return (
<div
className={className}
data-inline-fallback={String(options?.useInlineViewWhenSpaceIsLimited)}
data-side-by-side={String(options?.renderSideBySide)}
data-testid="diff-editor"
/>
),
useMonaco: () => null,
}));
);
}
function installMatchMedia(matches: boolean) {
Object.defineProperty(window, "matchMedia", {
@@ -65,6 +90,8 @@ describe("SkillDiffCard", () => {
beforeEach(() => {
getFileTextMock.mockReset();
getFileTextMock.mockResolvedValue({ text: "content" });
diffEditorMounts = 0;
diffEditorUnmounts = 0;
});
it("defaults to inline mode on narrow screens", async () => {
@@ -108,4 +135,34 @@ describe("SkillDiffCard", () => {
});
expect(screen.getByRole("button", { name: "Side-by-side" }).className).toContain("is-active");
});
it("keeps the diff editor mounted when toggling view mode", async () => {
installMatchMedia(false);
render(
<SkillDiffCard
skill={skill}
versions={[
makeVersion("skillVersions:1", "1.0.1"),
makeVersion("skillVersions:2", "1.0.2"),
]}
/>,
);
await waitFor(() => {
expect(screen.getByTestId("diff-editor")).toBeTruthy();
});
expect(diffEditorMounts).toBe(1);
expect(diffEditorUnmounts).toBe(0);
fireEvent.click(screen.getByRole("button", { name: "Inline" }));
await waitFor(() => {
expect(screen.getByTestId("diff-editor").getAttribute("data-side-by-side")).toBe("false");
});
expect(diffEditorMounts).toBe(1);
expect(diffEditorUnmounts).toBe(0);
});
});
-1
View File
@@ -402,7 +402,6 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
) : (
<ClientOnly fallback={<div className="diff-empty">Preparing diff</div>}>
<DiffEditor
key={`diff-${viewMode}`}
className={`diff-monaco diff-monaco-${viewMode}`}
original={leftText}
modified={rightText}
+121
View File
@@ -0,0 +1,121 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { usePreferences, getStoredPreferencesSnapshot } from "./preferences";
const PREFERENCES_KEY = "clawhub-preferences";
function PreferencesProbe() {
const { preferences, updatePreference, isAdvancedMode } = usePreferences();
return (
<div>
<div data-testid="density">{preferences.layoutDensity}</div>
<div data-testid="advanced">{String(isAdvancedMode)}</div>
<button
type="button"
onClick={() => updatePreference("advancedMode", !preferences.advancedMode)}
>
Toggle advanced
</button>
</div>
);
}
describe("preferences store", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("returns a stable snapshot when storage has not changed", () => {
const first = getStoredPreferencesSnapshot();
const second = getStoredPreferencesSnapshot();
expect(first).toBe(second);
expect(first.layoutDensity).toBe("comfortable");
});
it("falls back to defaults when localStorage reads throw", () => {
const getItemSpy = vi
.spyOn(Storage.prototype, "getItem")
.mockImplementation(() => {
throw new DOMException("blocked", "SecurityError");
});
render(<PreferencesProbe />);
expect(screen.getByTestId("density").textContent).toBe("comfortable");
expect(screen.getByTestId("advanced").textContent).toBe("false");
getItemSpy.mockRestore();
});
it("resets to defaults when the preference key is cleared in another tab", () => {
window.localStorage.setItem(
PREFERENCES_KEY,
JSON.stringify({
advancedMode: true,
layoutDensity: "compact",
}),
);
render(<PreferencesProbe />);
expect(screen.getByTestId("advanced").textContent).toBe("true");
expect(screen.getByTestId("density").textContent).toBe("compact");
window.localStorage.removeItem(PREFERENCES_KEY);
act(() => {
const event = Object.assign(new Event("storage"), {
key: PREFERENCES_KEY,
newValue: null,
oldValue: JSON.stringify({
advancedMode: true,
layoutDensity: "compact",
}),
storageArea: window.localStorage,
});
window.dispatchEvent(event);
});
expect(screen.getByTestId("advanced").textContent).toBe("false");
expect(screen.getByTestId("density").textContent).toBe("comfortable");
});
it("re-renders cleanly after a preference update", () => {
window.localStorage.setItem(
PREFERENCES_KEY,
JSON.stringify({
advancedMode: false,
}),
);
render(<PreferencesProbe />);
expect(screen.getByTestId("advanced").textContent).toBe("false");
fireEvent.click(screen.getByRole("button", { name: /toggle advanced/i }));
expect(screen.getByTestId("advanced").textContent).toBe("true");
expect(JSON.parse(window.localStorage.getItem(PREFERENCES_KEY) ?? "{}")).toMatchObject({
advancedMode: true,
});
});
it("does not cache a preference change when storage writes fail", () => {
const setItemSpy = vi
.spyOn(Storage.prototype, "setItem")
.mockImplementation(() => {
throw new DOMException("quota", "QuotaExceededError");
});
render(<PreferencesProbe />);
expect(screen.getByTestId("advanced").textContent).toBe("false");
fireEvent.click(screen.getByRole("button", { name: /toggle advanced/i }));
expect(screen.getByTestId("advanced").textContent).toBe("false");
expect(window.localStorage.getItem(PREFERENCES_KEY)).toBeNull();
setItemSpy.mockRestore();
});
});
+79 -19
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useSyncExternalStore } from "react";
const PREFERENCES_KEY = "clawhub-preferences";
@@ -66,36 +66,95 @@ const defaultPreferences: UserPreferences = {
// Simple event emitter for cross-tab sync
const listeners = new Set<() => void>();
let cachedPreferencesRaw: string | null = null;
let cachedPreferencesSnapshot: UserPreferences = defaultPreferences;
let hasCachedPreferences = false;
let removeStorageListener: (() => void) | null = null;
function normalizePreferences(parsed: Partial<UserPreferences> | null): UserPreferences {
if (!parsed) return defaultPreferences;
return { ...defaultPreferences, ...parsed };
}
function parsePreferences(raw: string | null): UserPreferences {
if (!raw) return defaultPreferences;
try {
const parsed = JSON.parse(raw) as Partial<UserPreferences>;
return normalizePreferences(parsed);
} catch {
return defaultPreferences;
}
}
function readStoredPreferences(): UserPreferences {
if (typeof window === "undefined") return defaultPreferences;
try {
const stored = window.localStorage.getItem(PREFERENCES_KEY);
if (hasCachedPreferences && stored === cachedPreferencesRaw) {
return cachedPreferencesSnapshot;
}
cachedPreferencesRaw = stored;
cachedPreferencesSnapshot = parsePreferences(stored);
hasCachedPreferences = true;
return cachedPreferencesSnapshot;
} catch {
cachedPreferencesRaw = null;
cachedPreferencesSnapshot = defaultPreferences;
hasCachedPreferences = true;
return cachedPreferencesSnapshot;
}
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
if (typeof window !== "undefined" && !removeStorageListener) {
const handleStorage = (event: StorageEvent) => {
if (event.storageArea !== window.localStorage || event.key !== PREFERENCES_KEY) {
return;
}
cachedPreferencesRaw = event.newValue;
cachedPreferencesSnapshot = parsePreferences(event.newValue);
hasCachedPreferences = true;
notifyListeners();
};
window.addEventListener("storage", handleStorage);
removeStorageListener = () => {
window.removeEventListener("storage", handleStorage);
removeStorageListener = null;
};
}
return () => {
listeners.delete(listener);
if (listeners.size === 0 && removeStorageListener) {
removeStorageListener();
}
};
}
function notifyListeners() {
listeners.forEach((listener) => listener());
}
function getStoredPreferences(): UserPreferences {
if (typeof window === "undefined") return defaultPreferences;
try {
const stored = window.localStorage.getItem(PREFERENCES_KEY);
if (!stored) return defaultPreferences;
const parsed = JSON.parse(stored) as Partial<UserPreferences>;
return { ...defaultPreferences, ...parsed };
} catch {
return defaultPreferences;
}
}
function savePreferences(prefs: UserPreferences) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(PREFERENCES_KEY, JSON.stringify(prefs));
notifyListeners();
const serialized = JSON.stringify(prefs);
window.localStorage.setItem(PREFERENCES_KEY, serialized);
cachedPreferencesRaw = serialized;
cachedPreferencesSnapshot = prefs;
hasCachedPreferences = true;
} catch {
// Storage might be full or disabled
}
notifyListeners();
}
// Server snapshot for SSR
@@ -106,7 +165,7 @@ function getServerSnapshot(): UserPreferences {
export function usePreferences() {
const preferences = useSyncExternalStore(
subscribe,
getStoredPreferences,
readStoredPreferences,
getServerSnapshot
);
@@ -114,13 +173,13 @@ export function usePreferences() {
key: K,
value: UserPreferences[K]
) => {
const current = getStoredPreferences();
const current = readStoredPreferences();
const updated = { ...current, [key]: value };
savePreferences(updated);
}, []);
const updatePreferences = useCallback((updates: Partial<UserPreferences>) => {
const current = getStoredPreferences();
const current = readStoredPreferences();
const updated = { ...current, ...updates };
savePreferences(updated);
}, []);
@@ -163,3 +222,4 @@ export function usePreferences() {
}
export { defaultPreferences };
export { readStoredPreferences as getStoredPreferencesSnapshot };
-12
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

-5
View File
@@ -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",
+46 -10
View File
@@ -1,41 +1,72 @@
import { createFileRoute, Link } from '@tanstack/react-router';
import type { LucideIcon } from 'lucide-react';
import {
Banknote,
Drama,
Eye,
EyeOff,
ImageOff,
ShieldOff,
UserX,
} from 'lucide-react';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { getSiteMode, getSiteName, getSiteUrlForMode } from '../lib/site';
const prohibitedCategories = [
export function renderWithInlineCode(text: string): (string | JSX.Element)[] {
const parts = text.split(/(`[^`]+`)/g);
return parts.map((part, i) => {
if (part.startsWith('`') && part.endsWith('`')) {
return (
<code key={i} className="about-inline-code">
{part.slice(1, -1)}
</code>
);
}
return part;
});
}
const prohibitedCategories: { title: string; icon: LucideIcon; examples: string }[] = [
{
title: 'Bypass and unauthorized access',
icon: ShieldOff,
examples:
'Auth bypass, account takeover, CAPTCHA bypass, Cloudflare or anti-bot evasion, rate-limit bypass, reusable session theft, live call or agent takeover.',
},
{
title: 'Platform abuse and ban evasion',
icon: UserX,
examples:
'Stealth accounts after bans, account warming/farming, fake engagement, multi-account automation, spam posting, marketplace or social automation built to avoid detection.',
},
{
title: 'Fraud and deception',
icon: Banknote,
examples:
'Fake certificates, fake invoices, deceptive payment flows, fake social proof, scam outreach, or synthetic-identity workflows built to create accounts for fraud.',
},
{
title: 'Privacy-invasive surveillance',
icon: Eye,
examples:
'Mass contact scraping for spam, doxxing, stalking, covert monitoring, biometric / face-matching workflows without clear consent, or buying, publishing, downloading, or operationalizing leaked data or breach dumps.',
},
{
title: 'Non-consensual impersonation',
icon: Drama,
examples:
'Face swap, digital twins, cloned influencers, fake personas, or other identity manipulation used to impersonate or mislead.',
},
{
title: 'Explicit sexual content',
icon: ImageOff,
examples:
'NSFW image, video, or text generation, especially wrappers around third-party APIs with safety checks disabled.',
},
{
title: 'Hidden or misleading execution',
icon: EyeOff,
examples:
'Obfuscated install commands, `curl | sh`, undeclared secret requirements, undeclared private-key use, or remote `npx @latest` execution without reviewability.',
},
@@ -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>
+321 -37
View File
@@ -1,18 +1,16 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useAction, useQuery } from "convex/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
ArrowRight,
ChevronRight,
Code2,
Download,
Layers,
Package,
Search,
Shield,
Star,
Users,
Zap,
} from "lucide-react";
import { api } from "../../convex/_generated/api";
import { SoulCard } from "../components/SoulCard";
@@ -30,6 +28,13 @@ function Home() {
return mode === "souls" ? <OnlyCrabsHome /> : <SkillsHome />;
}
// ═══ Slot machine word pool (13 words = 1/13 jackpot odds) ═══
const SLOT_WORDS = [
"Equip", "Install", "Unleash", "Ship", "Build",
"Create", "Deploy", "Launch", "Hack", "Scale",
"Forge", "Craft", "Wield",
];
function SkillsHome() {
type SkillPageEntry = {
skill: PublicSkill;
@@ -99,8 +104,263 @@ function SkillsHome() {
// Build carousel cards from highlighted data
const carouselCards = highlighted.length > 0 ? highlighted.slice(0, 6) : [];
// ═══ SLOT MACHINE EASTER EGG ═══
const HACK_INDEX = SLOT_WORDS.indexOf("Hack");
const clickTimesRef = useRef<number[]>([]);
const [slotState, setSlotState] = useState<
| null
| { phase: "spinning" }
| { phase: "stopped"; results: [number, number, number]; won: boolean; isHackJackpot: boolean }
>(null);
const slotTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [slotReelOffsets, setSlotReelOffsets] = useState<[number, number, number]>([0, 0, 0]);
const [stoppedReels, setStoppedReels] = useState<Set<number>>(new Set());
const confettiRef = useRef<HTMLCanvasElement>(null);
const spinIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const cooldownUntilRef = useRef<number>(0);
// Clean up timers/intervals if the component unmounts mid-spin
useEffect(() => {
return () => {
for (const t of slotTimersRef.current) clearTimeout(t);
if (spinIntervalRef.current) clearInterval(spinIntervalRef.current);
};
}, []);
const triggerSlots = useCallback(() => {
// Clean up any previous timers
for (const t of slotTimersRef.current) clearTimeout(t);
slotTimersRef.current = [];
if (spinIntervalRef.current) clearInterval(spinIntervalRef.current);
setSlotState({ phase: "spinning" });
setStoppedReels(new Set());
// Controlled odds: ~1/25 any jackpot, ~1/100 Hack jackpot
let r0: number, r1: number, r2: number;
const isJackpot = Math.random() < 1 / 25;
if (isJackpot) {
// 25% of jackpots are Hack (1/25 × 1/4 = 1/100 overall)
const isHack = Math.random() < 0.25;
if (isHack) {
r0 = HACK_INDEX;
} else {
// Pick any word except Hack
let idx = Math.floor(Math.random() * (SLOT_WORDS.length - 1));
if (idx >= HACK_INDEX) idx++;
r0 = idx;
}
r1 = r0;
r2 = r0;
} else {
// Normal spin — re-roll if accidental triple match
do {
r0 = Math.floor(Math.random() * SLOT_WORDS.length);
r1 = Math.floor(Math.random() * SLOT_WORDS.length);
r2 = Math.floor(Math.random() * SLOT_WORDS.length);
} while (r0 === r1 && r1 === r2);
}
const results: [number, number, number] = [r0, r1, r2];
const landed = new Set<number>();
// Animate fast offset cycling — only cycle reels that haven't landed
let frame = 0;
const spinInterval = setInterval(() => {
frame++;
setSlotReelOffsets((prev) => [
landed.has(0) ? prev[0] : (frame * 3) % SLOT_WORDS.length,
landed.has(1) ? prev[1] : (frame * 5 + 4) % SLOT_WORDS.length,
landed.has(2) ? prev[2] : (frame * 7 + 9) % SLOT_WORDS.length,
]);
}, 60);
spinIntervalRef.current = spinInterval;
// Stop reels sequentially with a satisfying stagger
const stopReel = (reelIdx: 0 | 1 | 2, delay: number) => {
const t = setTimeout(() => {
landed.add(reelIdx);
setStoppedReels((prev) => new Set(prev).add(reelIdx));
setSlotReelOffsets((prev) => {
const next = [...prev] as [number, number, number];
next[reelIdx] = results[reelIdx];
return next;
});
}, delay);
slotTimersRef.current.push(t);
};
stopReel(0, 1200);
stopReel(1, 1800);
const tFinal = setTimeout(() => {
clearInterval(spinInterval);
spinIntervalRef.current = null;
landed.add(2);
setStoppedReels(new Set([0, 1, 2]));
setSlotReelOffsets(results);
const won = r0 === r1 && r1 === r2;
const isHackJackpot = won && r0 === HACK_INDEX;
setSlotState({ phase: "stopped", results, won, isHackJackpot });
if (won) {
fireConfetti(isHackJackpot);
}
// Cooldown: 18s after win, 3s after loss
const displayTime = won ? 10000 : 2400;
const cooldownTime = won ? 18000 : 3000;
cooldownUntilRef.current = Date.now() + cooldownTime;
const tReset = setTimeout(() => {
setSlotState(null);
setStoppedReels(new Set());
}, displayTime);
slotTimersRef.current.push(tReset);
}, 2400);
slotTimersRef.current.push(tFinal);
}, []);
const handleLabelClick = useCallback(() => {
const now = Date.now();
// Respect cooldown period
if (now < cooldownUntilRef.current) return;
clickTimesRef.current.push(now);
// Keep only last 3 clicks
if (clickTimesRef.current.length > 3) {
clickTimesRef.current = clickTimesRef.current.slice(-3);
}
if (clickTimesRef.current.length === 3) {
const first = clickTimesRef.current[0];
const last = clickTimesRef.current[2];
if (last - first < 800 && !slotState) {
clickTimesRef.current = [];
triggerSlots();
}
}
}, [slotState, triggerSlots]);
const fireConfetti = (isHackJackpot: boolean) => {
const canvas = confettiRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
canvas.style.display = "block";
const STANDARD_COLORS = [
"#d4453a", "#ff6b6b", "#ffd93d", "#6bcb77",
"#4d96ff", "#ff6f91", "#845ec2", "#ffc75f",
];
const OCEAN_COLORS = [
"#0ea5e9", "#06b6d4", "#14b8a6", "#22d3ee",
"#38bdf8", "#67e8f9", "#a5f3fc", "#2dd4bf",
"#d4453a", "#ff6b6b",
];
const colors = isHackJackpot ? OCEAN_COLORS : STANDARD_COLORS;
type Particle = {
x: number; y: number; vx: number; vy: number;
w: number; h: number; color: string; rot: number; vr: number;
life: number; shape: "rect" | "bubble" | "claw";
};
const particles: Particle[] = [];
const count = isHackJackpot ? 200 : 150;
for (let i = 0; i < count; i++) {
const isBubble = isHackJackpot && Math.random() < 0.35;
const isClaw = isHackJackpot && !isBubble && Math.random() < 0.2;
particles.push({
x: canvas.width / 2 + (Math.random() - 0.5) * 300,
y: canvas.height * 0.35,
vx: (Math.random() - 0.5) * 18,
vy: isHackJackpot
? -Math.random() * 14 - 2 + (isBubble ? -4 : 0)
: -Math.random() * 16 - 4,
w: isBubble ? Math.random() * 8 + 4 : Math.random() * 10 + 4,
h: isBubble ? 0 : Math.random() * 6 + 3,
color: colors[Math.floor(Math.random() * colors.length)],
rot: Math.random() * Math.PI * 2,
vr: (Math.random() - 0.5) * 0.3,
life: isHackJackpot ? 1.3 : 1,
shape: isClaw ? "claw" : isBubble ? "bubble" : "rect",
});
}
const drawClaw = (ctx: CanvasRenderingContext2D, size: number) => {
// Simple lobster claw shape
ctx.beginPath();
ctx.moveTo(0, size * 0.5);
ctx.quadraticCurveTo(-size * 0.6, size * 0.2, -size * 0.4, -size * 0.3);
ctx.quadraticCurveTo(-size * 0.2, -size * 0.6, 0, -size * 0.3);
ctx.quadraticCurveTo(size * 0.2, -size * 0.6, size * 0.4, -size * 0.3);
ctx.quadraticCurveTo(size * 0.6, size * 0.2, 0, size * 0.5);
ctx.closePath();
ctx.fill();
};
let raf: number;
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
let alive = false;
for (const p of particles) {
if (p.life <= 0) continue;
alive = true;
p.x += p.vx;
p.y += p.vy;
p.vy += p.shape === "bubble" ? 0.15 : 0.4;
p.vx *= 0.99;
p.rot += p.vr;
p.life -= isHackJackpot ? 0.005 : 0.008;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.globalAlpha = Math.max(0, Math.min(1, p.life));
ctx.fillStyle = p.color;
if (p.shape === "bubble") {
ctx.beginPath();
ctx.arc(0, 0, p.w, 0, Math.PI * 2);
ctx.strokeStyle = p.color;
ctx.lineWidth = 1.5;
ctx.globalAlpha *= 0.7;
ctx.stroke();
ctx.globalAlpha *= 0.15;
ctx.fill();
} else if (p.shape === "claw") {
drawClaw(ctx, p.w);
} else {
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
}
ctx.restore();
}
if (alive) {
raf = requestAnimationFrame(draw);
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.style.display = "none";
}
};
raf = requestAnimationFrame(draw);
};
const renderSlotReel = (reelIdx: 0 | 1 | 2) => {
const offset = slotReelOffsets[reelIdx];
const word = SLOT_WORDS[offset];
const isReelSpinning = slotState !== null && !stoppedReels.has(reelIdx);
return (
<span className={`home-v2-slot-reel ${isReelSpinning ? "spinning" : ""}`}>
<span className="home-v2-slot-word">{word}</span>
</span>
);
};
return (
<main className="home-v2-main">
{/* Confetti canvas for slot machine wins */}
<canvas
ref={confettiRef}
className="home-v2-confetti"
style={{ display: "none" }}
/>
{/* ═══ HERO ═══ */}
<section className="home-v2-hero">
<div className="home-v2-hero-bg">
@@ -111,25 +371,59 @@ function SkillsHome() {
<div className="home-v2-ring home-v2-ring-3" />
</div>
<div className="home-v2-hero-label">BUILT BY THE COMMUNITY.</div>
<div
className={`home-v2-hero-label ${slotState ? "home-v2-hero-label-active" : ""}`}
onClick={handleLabelClick}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter") handleLabelClick(); }}
>
BUILT BY THE COMMUNITY.
</div>
<h1 className="home-v2-headline">
<span className="home-v2-headline-inner">
<span className="home-v2-action-word">Equip</span>
<span className="home-v2-sep" />
<span className="home-v2-action-word">Install</span>
<span className="home-v2-sep" />
<span className="home-v2-cycle-wrap">
<span className="home-v2-cycle-track">
<span className="home-v2-cycle-word">Unleash.</span>
<span className="home-v2-cycle-word">Ship.</span>
<span className="home-v2-cycle-word">Build.</span>
<span className="home-v2-cycle-word">Create.</span>
<span className="home-v2-cycle-word">Unleash.</span>
{slotState ? (
<h1 className={`home-v2-headline home-v2-headline-slots${
slotState.phase === "stopped" && slotState.won
? slotState.isHackJackpot
? " home-v2-headline-jackpot home-v2-headline-hack"
: " home-v2-headline-jackpot"
: ""
}`}>
{slotState.phase === "stopped" && slotState.isHackJackpot && (
<img
src="/clawd-mark.png"
alt=""
aria-hidden="true"
className="home-v2-hack-lobster"
/>
)}
<span className="home-v2-headline-inner">
{renderSlotReel(0)}
<span className="home-v2-sep" />
{renderSlotReel(1)}
<span className="home-v2-sep" />
{renderSlotReel(2)}
</span>
</h1>
) : (
<h1 className="home-v2-headline">
<span className="home-v2-headline-inner">
<span className="home-v2-action-word">Equip</span>
<span className="home-v2-sep" />
<span className="home-v2-action-word">Install</span>
<span className="home-v2-sep" />
<span className="home-v2-cycle-wrap">
<span className="home-v2-cycle-track">
<span className="home-v2-cycle-word">Unleash.</span>
<span className="home-v2-cycle-word">Ship.</span>
<span className="home-v2-cycle-word">Build.</span>
<span className="home-v2-cycle-word">Create.</span>
<span className="home-v2-cycle-word">Unleash.</span>
</span>
</span>
</span>
</span>
</h1>
</h1>
)}
<p className="home-v2-sub">Tools built by thousands, ready in one search.</p>
@@ -156,28 +450,28 @@ function SkillsHome() {
className="home-v2-suggestion"
onClick={() => handleSuggestion("self-improving agent")}
>
<Zap size={13} /> self-improving agent
self-improving agent
</button>
<button
type="button"
className="home-v2-suggestion"
onClick={() => handleSuggestion("GitHub integration")}
>
<Code2 size={13} /> GitHub integration
GitHub integration
</button>
<button
type="button"
className="home-v2-suggestion"
onClick={() => handleSuggestion("security soul")}
>
<Shield size={13} /> security soul
security soul
</button>
<button
type="button"
className="home-v2-suggestion"
onClick={() => handleSuggestion("dashboard builder")}
>
<Layers size={13} /> dashboard builder
dashboard builder
</button>
</div>
</section>
@@ -206,9 +500,6 @@ function SkillsHome() {
className="home-v2-c-card"
>
<div className="home-v2-c-head">
<div className="home-v2-c-icon">
<Zap size={18} />
</div>
<div className="home-v2-c-meta">
<div className="home-v2-c-name">
{entry.skill.displayName || entry.skill.slug}
@@ -218,9 +509,7 @@ function SkillsHome() {
</div>
</div>
</div>
<span className="home-v2-c-tag">
<Zap size={11} /> Skill
</span>
<span className="home-v2-c-tag">Skill</span>
<div className="home-v2-c-desc">
{entry.skill.summary || "A fresh skill bundle."}
</div>
@@ -249,9 +538,6 @@ function SkillsHome() {
className="home-v2-c-card"
>
<div className="home-v2-c-head">
<div className="home-v2-c-icon">
<Zap size={18} />
</div>
<div className="home-v2-c-meta">
<div className="home-v2-c-name">
{entry.skill.displayName || entry.skill.slug}
@@ -261,9 +547,7 @@ function SkillsHome() {
</div>
</div>
</div>
<span className="home-v2-c-tag">
<Zap size={11} /> Skill
</span>
<span className="home-v2-c-tag">Skill</span>
<div className="home-v2-c-desc">
{entry.skill.summary || "A fresh skill bundle."}
</div>
@@ -306,7 +590,7 @@ function SkillsHome() {
className="home-v2-cat-item"
>
<div className="home-v2-cat-icon">
<Zap size={20} />
<Package size={20} />
</div>
<div className="home-v2-cat-text">
<div className="home-v2-cat-name">Skills</div>
+3 -10
View File
@@ -9,9 +9,7 @@ import {
Moon,
RotateCcw,
Settings2,
Sparkles,
Sun,
Zap,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -495,8 +493,7 @@ export function Settings() {
{/* Code & Content Section - Advanced */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
<Sparkles size={14} className="text-[color:var(--accent)]" />
<Label className="text-sm font-semibold text-[color:var(--ink)]">
Code &amp; Content
</Label>
@@ -533,10 +530,7 @@ export function Settings() {
</SelectTrigger>
<SelectContent>
<SelectItem value="full">
<span className="flex items-center gap-2">
<Zap size={14} />
Full
</span>
Full
</SelectItem>
<SelectItem value="reduced">Reduced</SelectItem>
<SelectItem value="none">None</SelectItem>
@@ -604,8 +598,7 @@ export function Settings() {
{/* Experimental Features - Advanced */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
<Sparkles size={14} className="text-[color:var(--gold)]" />
<Label className="text-sm font-semibold text-[color:var(--ink)]">
Experimental
</Label>
+2 -2
View File
@@ -8,11 +8,11 @@ import {
MessageSquare,
Package,
Plug,
RefreshCw,
Search,
Shield,
Wrench,
X,
Zap,
} from "lucide-react";
import type { RefObject } from "react";
import { useMemo } from "react";
@@ -64,7 +64,7 @@ const CATEGORY_ICONS: Record<string, React.ReactNode> = {
"dev-tools": <Wrench size={13} />,
data: <Database size={13} />,
security: <Shield size={13} />,
automation: <Zap size={13} />,
automation: <RefreshCw size={13} />,
other: <Package size={13} />,
};
+297 -243
View File
@@ -93,13 +93,13 @@
color-scheme: dark;
/* OpenClaw — black, white, red brand palette */
--bg: #0a0a0a;
--bg-soft: #111111;
--bg-glow-1: #141414;
--bg-glow-2: #181818;
--surface: #121212;
--surface-muted: #171717;
--nav-bg: rgba(10, 10, 10, 0.96);
--bg: #060608;
--bg-soft: #0d0d0f;
--bg-glow-1: #101012;
--bg-glow-2: #141416;
--surface: #0e0e10;
--surface-muted: #131315;
--nav-bg: rgba(6, 6, 8, 0.96);
--ink: #fafafa;
--ink-soft: #a1a1a1;
--accent: #dc2626;
@@ -130,7 +130,7 @@
/* Form controls — crisp, modern */
--input-border: rgba(255, 255, 255, 0.1);
--input-bg: rgba(18, 18, 18, 0.9);
--input-bg: rgba(14, 14, 16, 0.9);
--input-placeholder: rgba(161, 161, 161, 0.7);
--input-focus-border: rgba(220, 38, 38, 0.5);
--input-focus-ring: rgba(220, 38, 38, 0.2);
@@ -141,7 +141,7 @@
--active-bg: rgba(220, 38, 38, 0.1);
/* Overlay */
--overlay-bg: rgba(9, 9, 11, 0.75);
--overlay-bg: rgba(5, 5, 7, 0.75);
/* Shadows — refined depth */
--shadow-dialog: 0 24px 48px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
@@ -173,12 +173,12 @@
--fs-2xl: 1.5rem;
--fs-3xl: 2rem;
/* Modern rounded corners — polished but efficient */
--r-lg: 12px;
/* Unified radius — one consistent size everywhere */
--r-lg: 8px;
--r-md: 8px;
--r-sm: 6px;
--r-xs: 4px;
--r-pill: 9999px;
--r-sm: 8px;
--r-xs: 8px;
--r-pill: 8px;
/* Modern typography — clean and readable */
--font-display: "Bricolage Grotesque", "Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
@@ -189,11 +189,11 @@
/* Light theme — OpenClaw black, white, red */
[data-theme-family="claw"][data-theme-resolved="light"] {
color-scheme: light;
--bg: #fafafa;
--bg-soft: #f5f5f5;
--surface: #ffffff;
--surface-muted: #f8f8f8;
--nav-bg: rgba(250, 250, 250, 0.96);
--bg: #faf6f1;
--bg-soft: #f5f1ec;
--surface: #fffcf8;
--surface-muted: #f8f5f0;
--nav-bg: rgba(250, 246, 241, 0.96);
--ink: #0a0a0a;
--ink-soft: #525252;
--accent: #dc2626;
@@ -215,7 +215,7 @@
/* Form controls — light */
--input-border: rgba(0, 0, 0, 0.12);
--input-bg: #ffffff;
--input-bg: #fffcf8;
--input-placeholder: rgba(82, 82, 82, 0.6);
--input-focus-border: rgba(220, 38, 38, 0.5);
--input-focus-ring: rgba(220, 38, 38, 0.15);
@@ -226,7 +226,7 @@
--active-bg: rgba(220, 38, 38, 0.08);
/* Overlay — light */
--overlay-bg: rgba(250, 250, 250, 0.75);
--overlay-bg: rgba(250, 246, 241, 0.75);
/* Shadows — light */
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
@@ -241,11 +241,11 @@
[data-theme-family="hub"] {
--page-max: 1536px;
--page-narrow: 900px;
--r-lg: 2px;
--r-md: 2px;
--r-sm: 1px;
--r-xs: 1px;
--r-pill: 2px;
--r-lg: 8px;
--r-md: 8px;
--r-sm: 8px;
--r-xs: 8px;
--r-pill: 8px;
--fs-xs: 0.72rem;
--fs-sm: 0.82rem;
--fs-base: 0.88rem;
@@ -263,13 +263,13 @@
[data-theme-family="hub"][data-theme-resolved="dark"] {
color-scheme: dark;
--bg: #0a0a0a;
--bg-soft: #111111;
--bg-glow-1: #111111;
--bg-glow-2: #111111;
--surface: #141414;
--surface-muted: #1a1a1a;
--nav-bg: rgba(10, 10, 10, 0.95);
--bg: #060608;
--bg-soft: #0d0d0f;
--bg-glow-1: #0d0d0f;
--bg-glow-2: #0d0d0f;
--surface: #101012;
--surface-muted: #161618;
--nav-bg: rgba(6, 6, 8, 0.95);
--ink: #e0e0e0;
--ink-soft: #818181;
--accent: #ef4444;
@@ -294,25 +294,25 @@
--status-error-bg: rgba(239, 68, 68, 0.12);
--status-error-fg: #f87171;
--input-border: rgba(255, 255, 255, 0.12);
--input-bg: rgba(20, 20, 20, 0.96);
--input-bg: rgba(16, 16, 18, 0.96);
--input-placeholder: rgba(184, 184, 184, 0.68);
--input-focus-border: rgba(239, 68, 68, 0.48);
--input-focus-ring: rgba(239, 68, 68, 0.16);
--label-fg: rgba(224, 224, 224, 0.78);
--hover-bg: rgba(255, 255, 255, 0.03);
--active-bg: rgba(239, 68, 68, 0.1);
--overlay-bg: rgba(10, 10, 10, 0.66);
--overlay-bg: rgba(6, 6, 8, 0.66);
--shadow-dialog: 0 24px 50px rgba(0, 0, 0, 0.35);
--shadow-card: none;
}
[data-theme-family="hub"][data-theme-resolved="light"] {
color-scheme: light;
--bg: #f0f0f0;
--bg-soft: #e8e8e8;
--surface: #ffffff;
--surface-muted: #f5f5f5;
--nav-bg: rgba(240, 240, 240, 0.95);
--bg: #ece8e3;
--bg-soft: #e4e0db;
--surface: #fdfaf6;
--surface-muted: #f5f1ec;
--nav-bg: rgba(236, 232, 227, 0.95);
--ink: #0a0a0a;
--ink-soft: #555555;
--accent: #dc2626;
@@ -337,14 +337,14 @@
--status-error-bg: rgba(220, 38, 38, 0.1);
--status-error-fg: #b91c1c;
--input-border: rgba(0, 0, 0, 0.18);
--input-bg: rgba(255, 255, 255, 0.94);
--input-bg: rgba(253, 250, 246, 0.94);
--input-placeholder: rgba(85, 85, 85, 0.64);
--input-focus-border: rgba(220, 38, 38, 0.42);
--input-focus-ring: rgba(220, 38, 38, 0.12);
--label-fg: rgba(10, 10, 10, 0.72);
--hover-bg: rgba(0, 0, 0, 0.03);
--active-bg: rgba(220, 38, 38, 0.08);
--overlay-bg: rgba(240, 240, 240, 0.7);
--overlay-bg: rgba(236, 232, 227, 0.7);
--shadow-dialog: 0 24px 50px rgba(0, 0, 0, 0.12);
--shadow-card: none;
}
@@ -640,15 +640,15 @@ code {
}
.site-footer {
padding: var(--space-6) var(--space-5);
padding: var(--space-3) var(--space-5);
margin-top: auto;
background: var(--surface);
border-top: 1px solid var(--line);
}
.site-footer-inner {
max-width: var(--page-max);
margin: 0 auto;
width: 100%;
padding: 0 var(--space-5);
}
.site-footer-divider {
@@ -700,8 +700,7 @@ code {
}
.navbar-inner {
max-width: var(--page-max);
margin: 0 auto;
width: 100%;
padding: 0 var(--space-5);
display: flex;
flex-direction: column;
@@ -710,7 +709,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;
@@ -913,6 +912,11 @@ code {
place-items: center;
overflow: hidden;
background: transparent;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--ink) 8%, transparent),
0 1px 2px rgba(0, 0, 0, 0.10),
0 2px 6px rgba(0, 0, 0, 0.05);
transition: box-shadow 0.2s ease;
}
.brand-mark img {
@@ -920,6 +924,7 @@ code {
height: 100%;
object-fit: cover;
border-radius: var(--r-sm);
transform: translateZ(0);
}
.nav-links {
@@ -976,6 +981,39 @@ code {
color: var(--ink-soft);
}
.mobile-nav-brand {
display: inline-flex;
align-items: center;
gap: 10px;
}
.mobile-nav-brand-mark {
width: 28px;
height: 28px;
border-radius: var(--r-sm);
display: grid;
place-items: center;
overflow: hidden;
background: transparent;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--ink) 8%, transparent),
0 1px 2px rgba(0, 0, 0, 0.08);
flex-shrink: 0;
}
.mobile-nav-brand-mark-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: var(--r-sm);
transform: translateZ(0);
}
.mobile-nav-brand-name {
display: inline-flex;
min-width: 0;
}
.mobile-nav-link {
display: inline-flex;
align-items: center;
@@ -1041,7 +1079,7 @@ code {
align-items: center;
gap: 8px;
flex: 0 0 auto;
min-width: 276px;
min-width: 0;
justify-content: flex-end;
}
@@ -4267,7 +4305,7 @@ code {
}
.site-footer {
padding: 0 18px 36px;
padding: 0 18px 18px;
}
}
@@ -7112,14 +7150,16 @@ html.theme-transition::view-transition-new(theme) {
.footer-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-5);
padding: var(--space-6) 0 var(--space-5);
gap: var(--space-3);
padding: var(--space-3) 0 var(--space-2);
text-align: center;
}
.footer-col {
display: flex;
flex-direction: column;
gap: var(--space-2);
align-items: center;
gap: var(--space-1);
}
.footer-col-title {
@@ -7128,7 +7168,7 @@ html.theme-transition::view-transition-new(theme) {
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-soft);
margin-bottom: var(--space-1);
margin-bottom: 2px;
}
.footer-col a,
@@ -7143,7 +7183,7 @@ html.theme-transition::view-transition-new(theme) {
.footer-bottom {
border-top: 1px solid var(--line);
padding: var(--space-4) 0;
padding: var(--space-2) 0;
text-align: center;
font-size: var(--fs-sm);
letter-spacing: 0.015em;
@@ -7165,14 +7205,14 @@ html.theme-transition::view-transition-new(theme) {
@media (max-width: 760px) {
.footer-grid {
grid-template-columns: repeat(2, 1fr);
gap: 20px;
gap: var(--space-3);
}
}
@media (max-width: 520px) {
.footer-grid {
grid-template-columns: 1fr;
gap: 20px;
gap: var(--space-3);
}
}
@@ -7366,15 +7406,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 +7459,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 +7513,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 +7544,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 +7749,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 +7815,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 +7891,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 {
@@ -7945,6 +8148,24 @@ html.theme-transition::view-transition-new(theme) {
color: var(--hv2-accent);
}
@media (max-width: 760px) {
.home-v2-suggestions {
gap: 6px;
margin-top: 12px;
}
.home-v2-suggestions-label {
font-size: 12px;
margin-right: 2px;
}
.home-v2-suggestion {
font-size: 12px;
padding: 5px 10px;
gap: 4px;
}
}
/* ═══ CAROUSEL ═══ */
.home-v2-carousel-section {
padding: 48px 0 0;
@@ -8542,61 +8763,11 @@ html.theme-transition::view-transition-new(theme) {
max-width: none;
}
/* ═══ MINIMAL FOOTER (home-v2 pages) ═══ */
/* Fix: use :has() since main is wrapped in ErrorBoundary */
.app-shell:has(.home-v2-main) > .site-footer .footer-grid {
display: none;
}
.app-shell:has(.home-v2-main) > .site-footer .site-footer-divider {
display: none;
}
.app-shell:has(.home-v2-main) > .site-footer {
border-top: 1px solid rgba(255,255,255,0.06);
padding: 0;
background: transparent;
}
.app-shell:has(.home-v2-main) > .site-footer .site-footer-inner {
max-width: none;
padding: 20px 48px;
}
.app-shell:has(.home-v2-main) > .site-footer .footer-bottom {
padding: 0;
margin: 0;
border: none;
font-size: 13px;
color: #555;
display: flex;
align-items: center;
justify-content: space-between;
}
.app-shell:has(.home-v2-main) > .site-footer .footer-bottom a {
color: #777;
}
.app-shell:has(.home-v2-main) > .site-footer .footer-bottom a:hover {
color: #aaa;
}
/* ═══ HOME V2 — Page-max constraint + full-bleed background ═══ */
.app-shell:has(.home-v2-main) {
background: #060608;
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) {
background: #faf6f1;
}
/* ═══ HOME V2 — Full-width nav override ═══ */
.app-shell:has(.home-v2-main) > header,
.app-shell:has(.home-v2-main) > nav,
.app-shell:has(.home-v2-main) .site-header,
.app-shell:has(.home-v2-main) .site-header-inner {
max-width: none;
width: 100%;
}
/* ═══ HOME V2 — Layout ═══ */
/* ═══ HOME V2 — Wider search + even dot spacing ═══ */
.home-v2-search-container {
max-width: 780px;
max-width: 920px;
}
.home-v2-search-bar {
padding: 8px 8px 8px 24px;
@@ -8616,10 +8787,6 @@ html.theme-transition::view-transition-new(theme) {
margin: 0 16px;
}
/* Override navbar max-width on home v2 */
.app-shell:has(.home-v2-main) .navbar-inner {
max-width: none;
}
/*
HOME V2 Cream / Peach / Tan Refinement (light + dark)
@@ -8862,55 +9029,6 @@ html.theme-transition::view-transition-new(theme) {
border-top-color: rgba(170, 125, 80, 0.12);
}
/* Light — footer */
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) > .site-footer {
border-top-color: rgba(170, 125, 80, 0.15);
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) > .site-footer .footer-bottom {
color: #9c8b7a;
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) > .site-footer .footer-bottom a {
color: #6b5c4e;
}
/* Light — nav/header blends with cream bg */
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar {
background: rgba(250, 246, 241, 0.95);
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .brand,
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .brand-name {
color: #1a1410;
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-search-home {
background: #fff8f2;
border-color: rgba(170, 125, 80, 0.2);
color: #6b5c4e;
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-search-home:hover {
border-color: rgba(170, 125, 80, 0.3);
background: #fff4ea;
color: #1a1410;
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-tab {
color: #6b5c4e;
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-tab:hover {
color: #1a1410;
background: rgba(160, 115, 72, 0.07);
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-tab.active,
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-tab[data-status="active"] {
color: #1a1410;
background: rgba(160, 115, 72, 0.1);
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-tab-secondary {
opacity: 1;
color: #7d6d5f;
}
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-tab-secondary:hover {
color: #1a1410;
}
/* Light — carousel nav arrows */
[data-theme-resolved="light"] .home-v2-carousel-nav button {
background: #fff8f2;
@@ -8971,44 +9089,6 @@ html.theme-transition::view-transition-new(theme) {
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.1);
}
/* Home v2 nav contrast */
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar {
background: rgba(6, 6, 8, 0.9);
border-bottom-color: rgba(255, 255, 255, 0.08);
}
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .brand,
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .brand-name {
color: rgba(255, 255, 255, 0.94);
}
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-search-home {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.72);
}
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-search-home:hover {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.9);
}
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-tab {
color: rgba(255, 255, 255, 0.74);
}
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-tab:hover {
color: rgba(255, 255, 255, 0.96);
background: rgba(255, 255, 255, 0.04);
}
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-tab.active,
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-tab[data-status="active"] {
color: rgba(255, 255, 255, 0.98);
background: rgba(255, 255, 255, 0.06);
}
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-tab-secondary {
opacity: 1;
color: rgba(255, 255, 255, 0.68);
}
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-tab-secondary:hover {
color: rgba(255, 255, 255, 0.9);
}
/*
HOME V2 Semi-rounded radius (Claw × Hub midpoint)
@@ -9016,45 +9096,19 @@ html.theme-transition::view-transition-new(theme) {
Claw: 6/8/12 Hub: 1/2/2 Ours: 4/7/10
*/
/* Override the home-v2 radius tokens */
/* Override the home-v2 radius tokens — unified 8px */
.home-v2-main {
--hv2-radius-sm: 4px;
--hv2-radius-md: 7px;
--hv2-radius-lg: 10px;
--hv2-radius-sm: 8px;
--hv2-radius-md: 8px;
--hv2-radius-lg: 8px;
}
/* Search bar — was 16px, now semi-rounded */
.home-v2-search-bar {
border-radius: 10px;
}
/* Search button — was 12px */
.home-v2-search-go {
border-radius: 7px;
}
/* kbd badge — was 5px */
.home-v2-search-bar kbd {
border-radius: 3px;
}
/* Carousel cards — picks up var(--hv2-radius-lg) = 10px ✓ */
/* Trending cards — picks up var(--hv2-radius-lg) = 10px ✓ */
/* Carousel card icon — was 10px */
.home-v2-c-icon {
border-radius: 7px;
}
/* Tag pills — was 100px (full pill), bring to semi-rounded pill */
.home-v2-c-tag {
border-radius: 20px;
}
/* Category icon — was 11px */
.home-v2-cat-icon {
border-radius: 7px;
}
/* Install buttons — picks up var(--hv2-radius-sm) = 4px ✓ */
.home-v2-search-bar { border-radius: 8px; }
.home-v2-search-go { border-radius: 8px; }
.home-v2-search-bar kbd { border-radius: 8px; }
.home-v2-c-icon { border-radius: 8px; }
.home-v2-c-tag { border-radius: 8px; }
.home-v2-cat-icon { border-radius: 8px; }
/* Suggestion pills — were likely pill-shaped, soften */
.home-v2-suggestion {