Compare commits

...
Author SHA1 Message Date
Val Alexander d2c856e68a fix: allow publisher id skill routes 2026-04-07 07:41:38 -05:00
Val Alexander 47822918c5 test: satisfy oxlint constructor rule 2026-04-07 07:39:25 -05:00
Val Alexander 74b834b4dd fix: resolve PR #1570 review follow-ups 2026-04-07 07:30:04 -05:00
Val AlexanderandClaude Opus 4.6 77abd47e16 fix(tests): add ConvexReactClient to convex/react mocks
All 6 test files that mock convex/react now include a stub
ConvexReactClient class so src/convex/client.ts can import
without crashing at module load time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 07:05:37 -05:00
Val AlexanderandClaude Opus 4.6 1f7de02144 fix(ci): provide dummy VITE_CONVEX_URL for test and coverage steps
Tests that import src/convex/client.ts crash at module load time
because getRequiredRuntimeEnv("VITE_CONVEX_URL") throws when the
env var is missing. Set a non-resolvable dummy URL for CI since
unit tests don't make real Convex calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 07:00:24 -05:00
Val AlexanderandClaude Opus 4.6 c8a515a785 feat: add subtle icons to Skills, Plugins, Souls navbar tabs
Each primary nav tab gets a monochrome lucide icon (Wrench, Plug, Ghost)
at 50% opacity for scanability. Icons brighten to 85% on hover and 100%
when active. Secondary tabs unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 06:51:15 -05:00
Val AlexanderandClaude Opus 4.6 cbacfb4812 fix: align plugin detail page width with skills page
Replace <Container> wrapper with .section class so plugin detail uses
the same max-width token (--page-max) and padding as skill detail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 05:51:13 -05:00
Val AlexanderandClaude Opus 4.6 84a48f28f9 fix: avoid CSS named color 'gray' causing Tailwind v4 token error
The CSS minifier converts #808080 to the named color 'gray', which
Tailwind v4 rejects as an illegal theme token value. Use #818181
instead — visually identical but not a named CSS color.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 05:30:50 -05:00
Val AlexanderandClaude Opus 4.6 ceebcc5bca Update branding to lobster logo with white rounded-corner backgrounds
Replace all logo/favicon assets with the new lobster logo. All variants
use a solid white background with rounded corners (12% radius).

Updated assets: clawd-logo.png (512), clawd-mark.png (256),
logo192.png, logo512.png, favicon.ico (multi-res), logo.svg.
Added explicit favicon/icon/manifest link tags to __root.tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 04:43:05 -05:00
Val AlexanderandClaude Opus 4.6 4d880acccc fix: address PR review — button height, one-shot fetch, query cap
- Sign-in button: add size="sm" (min-h 34px) to align with the 38px
  theme toggle group in the navbar
- UserBadge tooltip: replace reactive useQuery with one-shot
  ConvexHttpClient.query() so hover stats don't leave persistent
  subscriptions on the skills table
- getHoverStats: replace unbounded .collect() with .take(500) to cap
  per-transaction reads for prolific users

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 04:06:23 -05:00
Val AlexanderandClaude Opus 4.6 5e7e4e83bd Design system overhaul: unified tokens, component authority, standardized layout
Establish a single design token system and make React UI components the
canonical styling authority, replacing the dual CSS-class/inline-style
approach. No product behavior changes.

## Phase 1 — Design Foundation

- Add 25+ semantic CSS custom properties to :root (status colors, form
  controls, interactive states, overlay, shadows, layout widths) with
  matching light-theme overrides
- Expand @theme block from 24 → 80+ entries mapping all tokens to
  Tailwind v4 utilities (spacing, typography, status, form, layout)
- Migrate 11 ui/ primitives to use tokens instead of hardcoded rgba/hex:
  input, textarea, label, select, badge, button, card, dialog, sheet,
  dropdown-menu, toggle-group
- Introduce --accent-fg token for guaranteed text contrast on accent
  backgrounds across both themes
- Add --page-max (1536px), --page-narrow (900px), --page-min (320px)
  layout tokens; standardize all page/nav/footer max-widths
- Deprecate CSS classes (.btn, .tag, .card, .form-input, .form-label)
  with @deprecated comments — React components are now canonical

## Phase 2 — IA & Navigation

- Extract shared nav config (src/lib/nav-items.ts) consumed by Header
  and Footer, eliminating 3-place navigation duplication
- Add beforeLoad guard on /$owner/$slug catch-all route to validate
  owner handles and prevent confusing 404s on typos
- Add UserBadge hover tooltip with lazy-fetched stats (published skills,
  stars, downloads) via new getHoverStats Convex query
- Wrap app in TooltipProvider (src/components/AppProviders.tsx)

## Phase 3 — UI Sweep

- Migrate 54 .btn usages → <Button> across 13 route/component files
- Migrate 24 .tag usages → <Badge> across 12 files
- Eliminate ~89 inline style={{}} occurrences → Tailwind utilities
- Replace .card divs → <Card> component in detail/dashboard/search pages
- Fix text-white → text-accent-fg in 5 locations (SkillsToolbar,
  InstallSwitcher, toggle-group, .star-toggle, .btn-primary)
- Standardize all page max-widths to var(--page-max) token (was 6
  different values: 900–1450px)
- Add min-width: var(--page-min) to .app-shell
- Convert raw styled <a>/<button> elements to <Button asChild> pattern

## New files

- src/lib/nav-items.ts — shared navigation config
- src/lib/icon-sizes.ts — ICON_SM/MD/LG constants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 03:57:19 -05:00
65 changed files with 1301 additions and 836 deletions
+4
View File
@@ -27,9 +27,13 @@ jobs:
- name: Test
run: bun run test
env:
VITE_CONVEX_URL: https://example.invalid
- name: Coverage
run: bun run coverage
env:
VITE_CONVEX_URL: https://example.invalid
- name: ClawHub CLI Verify
run: bun run --cwd packages/clawhub verify
+35
View File
@@ -467,6 +467,41 @@ export const getByHandle = query({
},
});
/** Lightweight stats for user hover tooltips. Uses the skills by_owner index. */
export const getHoverStats = query({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
const skills = [];
let cursor: string | null = null;
for (;;) {
const page = await ctx.db
.query("skills")
.withIndex("by_owner", (q) => q.eq("ownerUserId", args.userId))
.paginate({ cursor, numItems: 100 });
skills.push(...page.page);
if (page.isDone) {
break;
}
cursor = page.continueCursor;
}
const active = skills.filter((s) => !s.softDeletedAt);
let totalStars = 0;
let totalDownloads = 0;
for (const s of active) {
totalStars += s.stats?.stars ?? 0;
totalDownloads += s.stats?.downloads ?? 0;
}
return {
publishedSkills: active.length,
totalStars,
totalDownloads,
};
},
});
export const getReservedHandleInternal = internalQuery({
args: { handle: v.string() },
handler: async (ctx, args) => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 704 B

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

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 169 KiB

+8 -7
View File
@@ -20,12 +20,14 @@ vi.mock("@convex-dev/auth/react", () => ({
}),
}));
const authStatusMock = vi.fn(() => ({
isAuthenticated: false,
isLoading: false,
me: null,
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => ({
isAuthenticated: false,
isLoading: false,
me: null,
}),
useAuthStatus: () => authStatusMock(),
}));
vi.mock("../lib/theme", () => ({
@@ -125,5 +127,4 @@ describe("Header", () => {
expect(screen.getAllByText("Users")).toHaveLength(2);
expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy();
expect(convexQueryMock).not.toHaveBeenCalled();
});
});
});});
+1
View File
@@ -17,6 +17,7 @@ const useAuthStatusMock = vi.fn();
let useActionCallCount = 0;
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useAction: () => {
const action = [previewImport, previewCandidate, importSkill][useActionCallCount % 3];
@@ -26,6 +26,7 @@ const useAuthStatusMock = vi.fn();
const originalFetch = globalThis.fetch;
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useMutation: () => generateUploadUrl,
useAction: () => publishRelease,
useQuery: () => undefined,
+8
View File
@@ -1,5 +1,13 @@
import { describe, expect, it, vi } from "vitest";
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
vi.mock("../convex/client", () => ({
convex: {},
convexHttp: { query: vi.fn() },
}));
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: { validateSearch?: unknown; component?: unknown }) => ({
__config: config,
+13
View File
@@ -6,6 +6,18 @@ import { SkillDetailPage } from "../components/SkillDetailPage";
const navigateMock = vi.fn();
const useAuthStatusMock = vi.fn();
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
vi.mock("../components/UserBadge", () => ({
UserBadge: () => null,
}));
vi.mock("../convex/client", () => ({
convex: {},
convexHttp: { query: vi.fn() },
}));
vi.mock("@tanstack/react-router", () => ({
Link: ({ children }: { children: unknown }) => children,
useNavigate: () => navigateMock,
@@ -15,6 +27,7 @@ const useQueryMock = vi.fn();
const getReadmeMock = vi.fn();
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => vi.fn(),
useAction: () => getReadmeMock,
+22
View File
@@ -17,6 +17,7 @@ vi.mock("@tanstack/react-router", () => ({
createFileRoute:
() =>
(config: {
beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown;
loader?: (args: { params: { owner: string; slug: string } }) => Promise<unknown>;
component?: unknown;
head?: unknown;
@@ -31,6 +32,7 @@ vi.mock("../lib/skillPage", () => ({
async function loadRoute() {
return (await import("../routes/$owner/$slug")).Route as unknown as {
__config: {
beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown;
loader?: (args: { params: { owner: string; slug: string } }) => Promise<unknown>;
head?: (args: {
params: { owner: string; slug: string };
@@ -45,6 +47,14 @@ async function loadRoute() {
};
}
async function runBeforeLoad(params: { owner: string; slug: string }) {
const route = await loadRoute();
const beforeLoad = route.__config.beforeLoad as ((args: {
params: { owner: string; slug: string };
}) => unknown) | undefined;
return beforeLoad?.({ params });
}
async function runLoader(params: { owner: string; slug: string }) {
const route = await loadRoute();
const loader = route.__config.loader as (args: {
@@ -71,6 +81,18 @@ function runHead(
}
describe("skill route loader", () => {
it("allows numeric owner handles in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "123abc", slug: "weather" })).not.toThrow();
});
it("allows raw owner ids in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "users:abc123", slug: "weather" })).not.toThrow();
});
it("allows raw publisher ids in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "publishers:abc123", slug: "weather" })).not.toThrow();
});
beforeEach(() => {
fetchSkillPageDataMock.mockReset();
});
@@ -23,6 +23,7 @@ vi.mock("@tanstack/react-router", () => ({
}));
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useAction: (...args: unknown[]) => convexReactMocks.useAction(...args),
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
}));
+1
View File
@@ -23,6 +23,7 @@ vi.mock("@tanstack/react-router", () => ({
}));
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useAction: (...args: unknown[]) => convexReactMocks.useAction(...args),
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
}));
+1
View File
@@ -22,6 +22,7 @@ const useAuthStatusMock = vi.fn();
let useActionCallCount = 0;
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => generateUploadUrl,
useAction: () => {
+7 -4
View File
@@ -3,6 +3,7 @@ import { useEffect, useRef } from "react";
import { convex } from "../convex/client";
import { getUserFacingAuthError, normalizeAuthErrorMessage } from "../lib/authErrorMessage";
import { clearAuthError, setAuthError } from "../lib/useAuthError";
import { TooltipProvider } from "./ui/tooltip";
import { UserBootstrap } from "./UserBootstrap";
function getPendingAuthCode() {
@@ -82,10 +83,12 @@ export function AuthErrorHandler() {
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<ConvexAuthProvider client={convex} shouldHandleCode={false}>
<AuthCodeHandler />
<AuthErrorHandler />
<UserBootstrap />
{children}
<TooltipProvider delayDuration={400}>
<AuthCodeHandler />
<AuthErrorHandler />
<UserBootstrap />
{children}
</TooltipProvider>
</ConvexAuthProvider>
);
}
+1 -11
View File
@@ -69,17 +69,7 @@ function DeploymentDriftBannerContent() {
return (
<div
role="alert"
style={{
margin: "16px auto 0",
width: "min(1100px, calc(100vw - 32px))",
border: "1px solid #f59e0b",
background: "#fff7ed",
color: "#9a3412",
borderRadius: "14px",
padding: "12px 16px",
fontSize: "0.95rem",
lineHeight: 1.4,
}}
className="mx-auto mt-4 w-[min(1100px,calc(100vw-32px))] rounded-[14px] border border-status-warning-fg/40 bg-status-warning-bg px-4 py-3 text-[0.95rem] leading-[1.4] text-status-warning-fg"
>
Deploy mismatch detected. Frontend expects backend build <code>{drift.expectedBuildSha}</code>{" "}
but Convex reports <code>{drift.actualBuildSha}</code>.
+24 -84
View File
@@ -1,4 +1,5 @@
import { Link } from "@tanstack/react-router";
import { FOOTER_NAV_SECTIONS } from "../lib/nav-items";
import { getSiteName } from "../lib/site";
export function Footer() {
@@ -8,90 +9,29 @@ export function Footer() {
<div className="site-footer-inner">
<div className="site-footer-divider" aria-hidden="true" />
<div className="footer-grid">
<div className="footer-col">
<h4 className="footer-col-title">Browse</h4>
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
>
Skills
</Link>
<Link to="/plugins">Plugins</Link>
<Link
to="/souls"
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined, focus: undefined }}
>
Souls
</Link>
<Link to="/users">Users</Link>
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: true,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
>
Staff Picks
</Link>
<Link to="/search" search={{ q: undefined, type: undefined }}>
Search
</Link>
</div>
<div className="footer-col">
<h4 className="footer-col-title">Publish</h4>
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
Publish Skill
</Link>
<Link
to="/publish-plugin"
search={{
ownerHandle: undefined,
name: undefined,
displayName: undefined,
family: undefined,
nextVersion: undefined,
sourceRepo: undefined,
}}
>
Publish Plugin
</Link>
<a href="https://github.com/openclaw/clawhub" target="_blank" rel="noreferrer">
Documentation
</a>
</div>
<div className="footer-col">
<h4 className="footer-col-title">Community</h4>
<a href="https://github.com/openclaw/clawhub" target="_blank" rel="noreferrer">
GitHub
</a>
<Link to="/about">About</Link>
<a href="https://openclaw.ai" target="_blank" rel="noreferrer">
OpenClaw
</a>
</div>
<div className="footer-col">
<h4 className="footer-col-title">Platform</h4>
<span>MIT Licensed</span>
<a href="https://vercel.com" target="_blank" rel="noreferrer">
Deployed on Vercel
</a>
<a href="https://www.convex.dev" target="_blank" rel="noreferrer">
Powered by Convex
</a>
</div>
{FOOTER_NAV_SECTIONS.map((section) => (
<div key={section.title} className="footer-col">
<h4 className="footer-col-title">{section.title}</h4>
{section.items.map((item) => {
if (item.kind === "link") {
return (
<Link key={item.label} to={item.to} search={item.search ?? {}}>
{item.label}
</Link>
);
}
if (item.kind === "external") {
return (
<a key={item.label} href={item.href} target="_blank" rel="noreferrer">
{item.label}
</a>
);
}
// kind === "text"
return <span key={item.label}>{item.label}</span>;
})}
</div>
))}
</div>
<div className="footer-bottom">
<span>
+57 -165
View File
@@ -1,15 +1,22 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { Link, useNavigate } from "@tanstack/react-router";
import { Github, Menu, Monitor, Moon, Search, Sun } from "lucide-react";
import { Ghost, Github, Menu, Monitor, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import { gravatarUrl } from "../lib/gravatar";
import {
filterNavItems,
type NavIconName,
PRIMARY_NAV_ITEMS,
SECONDARY_NAV_ITEMS,
} from "../lib/nav-items";
import { isModerator } from "../lib/roles";
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
import { applyTheme, useThemeMode } from "../lib/theme";
import { startThemeTransition } from "../lib/theme-transition";
import { setAuthError, useAuthError } from "../lib/useAuthError";
import { useAuthStatus } from "../lib/useAuthStatus";
import { Button } from "./ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@@ -19,6 +26,12 @@ import {
} from "./ui/dropdown-menu";
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
const NAV_ICONS: Record<NavIconName, React.ComponentType<{ size?: number; className?: string }>> = {
wrench: Wrench,
plug: Plug,
ghost: Ghost,
};
export default function Header() {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const { signIn, signOut } = useAuthActions();
@@ -34,6 +47,13 @@ export default function Header() {
const handle = me?.handle ?? me?.displayName ?? "user";
const initial = (me?.displayName ?? me?.name ?? handle).charAt(0).toUpperCase();
const isStaff = isModerator(me);
const hasResolvedUser = Boolean(me);
const navCtx = useMemo(
() => ({ isSoulMode, isAuthenticated: hasResolvedUser, isStaff }),
[hasResolvedUser, isSoulMode, isStaff],
);
const primaryItems = useMemo(() => filterNavItems(PRIMARY_NAV_ITEMS, navCtx), [navCtx]);
const secondaryItems = useMemo(() => filterNavItems(SECONDARY_NAV_ITEMS, navCtx), [navCtx]);
const { error: authError, clear: clearAuthError } = useAuthError();
const signInRedirectTo = getCurrentRelativeUrl();
@@ -115,87 +135,20 @@ export default function Header() {
<a href={clawHubUrl}>ClawHub</a>
</DropdownMenuItem>
) : null}
<DropdownMenuItem asChild>
{isSoulMode ? (
<Link
to="/souls"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
>
Souls
</Link>
) : (
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
>
Skills
</Link>
)}
</DropdownMenuItem>
{isSoulMode ? null : (
<DropdownMenuItem asChild>
<Link to="/plugins">Plugins</Link>
</DropdownMenuItem>
)}
{isSoulMode ? null : (
<DropdownMenuItem asChild>
<Link
to="/souls"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
>
Souls
{primaryItems.map((item) => (
<DropdownMenuItem key={item.to + item.label} asChild>
<Link to={item.to} search={item.search ?? {}}>
{item.label}
</Link>
</DropdownMenuItem>
)}
{isSoulMode ? null : (
<DropdownMenuItem asChild>
<Link to="/users" search={{ q: undefined }}>
Users
))}
{secondaryItems.map((item) => (
<DropdownMenuItem key={item.to + item.label} asChild>
<Link to={item.to} search={item.search ?? {}}>
{item.label}
</Link>
</DropdownMenuItem>
)}
{isSoulMode ? null : (
<DropdownMenuItem asChild>
<Link to="/about">About</Link>
</DropdownMenuItem>
)}
{me ? (
<DropdownMenuItem asChild>
<Link to="/stars">Stars</Link>
</DropdownMenuItem>
) : null}
{me ? (
<DropdownMenuItem asChild>
<Link to="/dashboard">Dashboard</Link>
</DropdownMenuItem>
) : null}
{isStaff ? (
<DropdownMenuItem asChild>
<Link to="/management" search={{ skill: undefined }}>
Management
</Link>
</DropdownMenuItem>
) : null}
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="h-4 w-4" aria-hidden="true" />
@@ -263,26 +216,21 @@ export default function Header() {
) : (
<>
{authError ? (
<div className="error" role="alert" style={{ fontSize: "0.85rem", marginRight: 8 }}>
<div className="error mr-2 text-[0.85rem]" role="alert">
{authError}{" "}
<button
type="button"
onClick={clearAuthError}
aria-label="Dismiss"
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "inherit",
padding: "0 2px",
}}
className="cursor-pointer border-none bg-transparent px-0.5 py-0 text-inherit"
>
&times;
</button>
</div>
) : null}
<button
className="btn btn-primary"
<Button
variant="primary"
size="sm"
type="button"
disabled={isLoading}
onClick={() => {
@@ -298,7 +246,7 @@ export default function Header() {
<Github size={16} aria-hidden="true" />
<span className="sign-in-label">Sign in</span>
<span className="sign-in-provider">with GitHub</span>
</button>
</Button>
</>
)}
</div>
@@ -327,88 +275,32 @@ export default function Header() {
ClawHub
</a>
) : null}
{isSoulMode ? (
<Link
to="/souls"
className="navbar-tab"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
>
Souls
</Link>
) : (
<Link
to="/skills"
className="navbar-tab"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
>
Skills
</Link>
)}
{isSoulMode ? null : (
<Link to="/plugins" className="navbar-tab">
Plugins
</Link>
)}
{isSoulMode ? null : (
<Link
to="/souls"
className="navbar-tab"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
>
Souls
</Link>
)}
{primaryItems.map((item) => {
const Icon = item.icon ? NAV_ICONS[item.icon] : null;
return (
<Link
key={item.to + item.label}
to={item.to}
className="navbar-tab"
search={item.search ?? {}}
>
{Icon ? <Icon size={14} className="opacity-50" aria-hidden="true" /> : null}
{item.label}
</Link>
);
})}
</div>
<div className="navbar-tabs-secondary">
{isSoulMode ? null : (
<Link to="/users" search={{ q: undefined }} className="navbar-tab navbar-tab-secondary">
Users
</Link>
)}
{isSoulMode ? null : (
<Link to="/about" className="navbar-tab navbar-tab-secondary">
About
</Link>
)}
{me ? (
<Link to="/stars" className="navbar-tab navbar-tab-secondary">
Stars
</Link>
) : null}
{me ? (
<Link to="/dashboard" className="navbar-tab navbar-tab-secondary">
Dashboard
</Link>
) : null}
{isStaff ? (
{secondaryItems.map((item) => (
<Link
to="/management"
search={{ skill: undefined }}
key={item.to + item.label}
to={item.to}
search={item.search ?? {}}
className="navbar-tab navbar-tab-secondary"
>
Manage
{item.label === "Management" ? "Manage" : item.label}
</Link>
) : null}
))}
</div>
</nav>
</div>
+1 -1
View File
@@ -43,7 +43,7 @@ export function InstallSwitcher({ exampleSlug = "sonoscli" }: InstallSwitcherPro
type="button"
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
pm === entry.id
? "bg-[color:var(--accent)] text-white shadow-sm"
? "bg-accent text-accent-fg shadow-sm"
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
role="tab"
+3 -2
View File
@@ -1,5 +1,6 @@
import { Link } from "@tanstack/react-router";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Badge } from "./ui/badge";
import { familyLabel } from "../lib/packageLabels";
import type { PackageListItem } from "../lib/packageApi";
@@ -20,8 +21,8 @@ export function PluginListItem({ item }: PluginListItemProps) {
</>
) : null}
<span className="skill-list-item-name">{item.displayName}</span>
<span className="tag tag-compact">{familyLabel(item.family)}</span>
{item.isOfficial ? <span className="tag tag-compact tag-accent">Verified</span> : null}
<Badge variant="compact">{familyLabel(item.family)}</Badge>
{item.isOfficial ? <Badge variant="accent">Verified</Badge> : null}
</div>
<p className="skill-list-item-summary">{item.summary ?? "Plugin package for agent workflows."}</p>
<div className="skill-list-item-meta">
+6 -5
View File
@@ -1,6 +1,7 @@
import { Link } from "@tanstack/react-router";
import type { ReactNode } from "react";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Badge } from "./ui/badge";
import type { PublicSkill } from "../lib/publicUser";
type SkillCardProps = {
@@ -32,15 +33,15 @@ export function SkillCard({
{hasTags ? (
<div className="skill-card-tags">
{badges.map((label) => (
<div key={label} className="tag">
<Badge key={label}>
{label}
</div>
</Badge>
))}
{chip ? <div className="tag tag-accent tag-compact">{chip}</div> : null}
{chip ? <Badge variant="accent">{chip}</Badge> : null}
{platformLabels?.map((label) => (
<div key={label} className="tag tag-compact">
<Badge key={label} variant="compact">
{label}
</div>
</Badge>
))}
</div>
) : null}
+16 -15
View File
@@ -7,6 +7,7 @@ import type { Doc, Id } from "../../convex/_generated/dataModel";
import { canManageSkill, isModerator } from "../lib/roles";
import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage";
import { useAuthStatus } from "../lib/useAuthStatus";
import { Card } from "./ui/card";
import { ClientOnly } from "./ClientOnly";
import { SkillCommentsPanel } from "./SkillCommentsPanel";
import { SkillDetailTabs, type DetailTab } from "./SkillDetailTabs";
@@ -330,9 +331,9 @@ export function SkillDetailPage({
if (isLoadingSkill || wantsCanonicalRedirect) {
return (
<main className="section">
<div className="card">
<Card>
<div className="loading-indicator">Loading skill</div>
</div>
</Card>
</main>
);
}
@@ -340,7 +341,7 @@ export function SkillDetailPage({
if (result === null || !skill) {
return (
<main className="section">
<div className="card">Skill not found.</div>
<Card>Skill not found.</Card>
</main>
);
}
@@ -403,25 +404,25 @@ export function SkillDetailPage({
<div className="detail-layout">
<div className="detail-main">
{nixSnippet ? (
<div className="card">
<h3 style={{ margin: 0, fontSize: "var(--text-base)", fontWeight: 600 }}>
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Install via Nix
</h3>
<pre className="hero-install-code" style={{ marginTop: 8 }}>
<pre className="hero-install-code mt-2">
{nixSnippet}
</pre>
</div>
</Card>
) : null}
{configExample ? (
<div className="card">
<h3 style={{ margin: 0, fontSize: "var(--text-base)", fontWeight: 600 }}>
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Config example
</h3>
<pre className="hero-install-code" style={{ marginTop: 8 }}>
<pre className="hero-install-code mt-2">
{configExample}
</pre>
</div>
</Card>
) : null}
<SkillDetailTabs
@@ -442,14 +443,14 @@ export function SkillDetailPage({
<ClientOnly
fallback={
<div className="card">
<h2 className="section-title" style={{ fontSize: "1.2rem", margin: 0 }}>
<Card>
<h2 className="section-title text-[1.2rem] m-0">
Comments
</h2>
<p className="section-subtitle" style={{ marginTop: 12, marginBottom: 0 }}>
<p className="section-subtitle mt-3 mb-0">
Loading comments...
</p>
</div>
</Card>
}
>
<SkillCommentsPanel
+2 -2
View File
@@ -99,14 +99,14 @@ export function SkillDetailTabs({
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readmeContent}</ReactMarkdown>
</div>
) : readmeError ? (
<div className="empty-state" style={{ padding: "var(--space-6) var(--space-4)" }}>
<div className="empty-state px-[var(--space-4)] py-[var(--space-6)]">
<p className="empty-state-title">No README available</p>
<p className="empty-state-body">
This skill doesn't have a SKILL.md file yet.
</p>
</div>
) : (
<div className="stat" style={{ padding: 16 }}>
<div className="stat p-4">
Loading README...
</div>
)}
+6 -5
View File
@@ -13,6 +13,7 @@ import {
selectDefaultFilePath,
sortVersionsBySemver,
} from "../lib/diffing";
import { Button } from "./ui/button";
import { ClientOnly } from "./ClientOnly";
type SkillDiffCardProps = {
@@ -278,10 +279,10 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
<div className={containerClass}>
<div className="diff-header">
<div>
<h2 className="section-title" style={{ fontSize: "1.2rem", margin: 0 }}>
<h2 className="section-title text-[1.2rem] m-0">
Compare versions
</h2>
<p className="section-subtitle" style={{ margin: 0 }}>
<p className="section-subtitle m-0">
Inline or side-by-side diff for any file.
</p>
</div>
@@ -323,8 +324,8 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
{renderOptions(versionOptions)}
</select>
</div>
<button
className="btn diff-swap"
<Button
className="diff-swap"
type="button"
onClick={() => {
setLeftVersionId(rightVersionId);
@@ -333,7 +334,7 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
disabled={!leftVersionId || !rightVersionId}
>
Swap
</button>
</Button>
<div className="diff-select">
<label htmlFor="diff-right">Right</label>
<select
+2 -2
View File
@@ -89,10 +89,10 @@ export function SkillFilesPanel({
<div className="file-browser">
<div className="file-list">
<div className="file-list-header">
<h3 className="section-title" style={{ fontSize: "1.05rem", margin: 0 }}>
<h3 className="section-title text-[1.05rem] m-0">
Files
</h3>
<span className="section-subtitle" style={{ margin: 0 }}>
<span className="section-subtitle m-0">
{latestFiles.length} total
</span>
</div>
+25 -21
View File
@@ -7,6 +7,8 @@ import { formatCompactStat, formatSkillStatsTriplet } from "../lib/numberFormat"
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { type LlmAnalysis, SecurityScanResults } from "./SkillSecurityScanResults";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { UserBadge } from "./UserBadge";
export type SkillModerationInfo = {
@@ -188,13 +190,13 @@ export function SkillHeader({
<div className="skill-hero-header">
<div className="skill-hero-title">
<div className="skill-hero-title-row">
<h1 className="section-title" style={{ margin: 0 }}>
<h1 className="section-title m-0">
{skill.displayName}
</h1>
{latestVersion?.version ? (
<span className="plugin-version-badge">v{latestVersion.version}</span>
) : null}
{nixPlugin ? <span className="tag tag-accent">Plugin bundle (nix)</span> : null}
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
</div>
<p className="section-subtitle">{skill.summary ?? "No summary provided."}</p>
@@ -210,11 +212,11 @@ export function SkillHeader({
<div className="skill-hero-inline-meta">
<div className="skill-hero-stats-row">
<span className="stat"> {formattedStats.stars}</span>
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat"><Package size={14} aria-hidden="true" /> {formattedStats.downloads}</span>
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">{formatCompactStat(skill.stats.installsCurrent ?? 0)} current</span>
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">{formattedStats.installsAllTime} all-time</span>
</div>
<div className="skill-hero-meta-row">
@@ -227,7 +229,7 @@ export function SkillHeader({
/>
{forkOf && forkOfHref ? (
<>
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
{forkOfLabel}{" "}
<a href={forkOfHref}>
@@ -240,7 +242,7 @@ export function SkillHeader({
) : null}
{canonicalHref ? (
<>
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
canonical:{" "}
<a href={canonicalHref}>
@@ -255,14 +257,14 @@ export function SkillHeader({
<div className="skill-hero-badges">
{getSkillBadges(skill).map((badge) => (
<span key={badge} className="tag tag-compact">
<Badge key={badge} variant="compact">
{badge}
</span>
</Badge>
))}
{isStaff && staffVisibilityTag ? (
<span className={`tag tag-compact${isAutoHidden || isRemoved ? " tag-accent" : ""}`}>
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
{staffVisibilityTag}
</span>
</Badge>
) : null}
</div>
</div>
@@ -279,14 +281,16 @@ export function SkillHeader({
</button>
) : null}
{isAuthenticated ? (
<button className="btn btn-sm btn-ghost" type="button" onClick={onOpenReport}>
<Button variant="ghost" size="sm" type="button" onClick={onOpenReport}>
Report
</button>
</Button>
) : null}
{isStaff ? (
<Link className="btn btn-sm" to="/management" search={{ skill: skill.slug }}>
Manage
</Link>
<Button asChild size="sm">
<Link to="/management" search={{ skill: skill.slug }}>
Manage
</Link>
</Button>
) : null}
</div>
</div>
@@ -354,12 +358,12 @@ export function SkillHeader({
<div className="skill-tag-row">
{tagEntries.length === 0 ? (
<span className="section-subtitle" style={{ margin: 0 }}>
<span className="section-subtitle m-0">
No tags yet.
</span>
) : (
tagEntries.map(([tag, versionId]) => (
<span key={tag} className="tag">
<Badge key={tag}>
{tag}
<span className="tag-meta">
v{versionById.get(versionId)?.version ?? versionId}
@@ -375,7 +379,7 @@ export function SkillHeader({
×
</button>
) : null}
</span>
</Badge>
))
)}
</div>
@@ -405,9 +409,9 @@ export function SkillHeader({
</option>
))}
</select>
<button className="btn" type="submit">
<Button type="submit">
Update tag
</button>
</Button>
</form>
) : null}
+18 -30
View File
@@ -1,4 +1,5 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { Badge } from "./ui/badge";
import { formatInstallCommand, formatInstallLabel } from "./skillDetailUtils";
type SkillInstallCardProps = {
@@ -35,11 +36,11 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
<div className="skill-hero-panels">
{hasRuntimeRequirements ? (
<div className="skill-panel">
<h3 className="section-title" style={{ fontSize: "1rem", margin: 0 }}>
<h3 className="section-title text-[1rem] m-0">
Runtime requirements
</h3>
<div className="skill-panel-body">
{clawdis?.emoji ? <div className="tag">{clawdis.emoji} Clawdis</div> : null}
{clawdis?.emoji ? <Badge>{clawdis.emoji} Clawdis</Badge> : null}
{osLabels.length ? (
<div className="stat">
<strong>OS</strong>
@@ -79,31 +80,24 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
{envVars.length > 0 ? (
<div className="stat">
<strong>Environment variables</strong>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.25rem",
marginTop: "0.25rem",
}}
>
<div className="flex flex-col gap-1 mt-1">
{envVars.map((env, index) => (
<div
key={`${env.name}-${index}`}
style={{ display: "flex", alignItems: "baseline", gap: "0.5rem" }}
className="flex items-baseline gap-2"
>
<code style={{ fontSize: "0.85rem" }}>{env.name}</code>
<code className="text-[0.85rem]">{env.name}</code>
{env.required === false ? (
<span style={{ color: "var(--ink-soft)", fontSize: "0.75rem" }}>
<span className="text-ink-soft text-[0.75rem]">
optional
</span>
) : env.required === true ? (
<span style={{ color: "var(--ink-accent)", fontSize: "0.75rem" }}>
<span className="text-ink-accent text-[0.75rem]">
required
</span>
) : null}
{env.description ? (
<span style={{ color: "var(--ink-soft)", fontSize: "0.8rem" }}>
<span className="text-ink-soft text-[0.8rem]">
{env.description}
</span>
) : null}
@@ -117,7 +111,7 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
) : null}
{hasDependencies ? (
<div className="skill-panel">
<h3 className="section-title" style={{ fontSize: "1rem", margin: 0 }}>
<h3 className="section-title text-[1rem] m-0">
Dependencies
</h3>
<div className="skill-panel-body">
@@ -125,25 +119,19 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
<div key={`${dep.name}-${index}`} className="stat">
<div>
<strong>{dep.name}</strong>
<span
style={{
color: "var(--ink-soft)",
fontSize: "0.85rem",
marginLeft: "0.5rem",
}}
>
<span className="text-ink-soft text-[0.85rem] ml-2">
{dep.type}
{dep.version ? ` ${dep.version}` : ""}
</span>
{dep.url ? (
<div style={{ fontSize: "0.8rem", wordBreak: "break-all" }}>
<div className="text-[0.8rem] break-all">
<a href={dep.url} target="_blank" rel="noopener noreferrer">
{dep.url}
</a>
</div>
) : null}
{dep.repository && dep.repository !== dep.url ? (
<div style={{ fontSize: "0.8rem" }}>
<div className="text-[0.8rem]">
<a href={dep.repository} target="_blank" rel="noopener noreferrer">
Source
</a>
@@ -157,7 +145,7 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
) : null}
{hasInstallSpecs ? (
<div className="skill-panel">
<h3 className="section-title" style={{ fontSize: "1rem", margin: 0 }}>
<h3 className="section-title text-[1rem] m-0">
Install
</h3>
<div className="skill-panel-body">
@@ -168,7 +156,7 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
<div>
<strong>{spec.label ?? formatInstallLabel(spec)}</strong>
{spec.bins?.length ? (
<div style={{ color: "var(--ink-soft)", fontSize: "0.85rem" }}>
<div className="text-ink-soft text-[0.85rem]">
Bins: {spec.bins.join(", ")}
</div>
) : null}
@@ -182,14 +170,14 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
) : null}
{hasLinks ? (
<div className="skill-panel">
<h3 className="section-title" style={{ fontSize: "1rem", margin: 0 }}>
<h3 className="section-title text-[1rem] m-0">
Links
</h3>
<div className="skill-panel-body">
{links?.homepage ? (
<div className="stat">
<strong>Homepage</strong>
<a href={links.homepage} target="_blank" rel="noopener noreferrer" style={{ wordBreak: "break-all" }}>
<a href={links.homepage} target="_blank" rel="noopener noreferrer" className="break-all">
{links.homepage}
</a>
</div>
@@ -197,7 +185,7 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
{links?.repository ? (
<div className="stat">
<strong>Repository</strong>
<a href={links.repository} target="_blank" rel="noopener noreferrer" style={{ wordBreak: "break-all" }}>
<a href={links.repository} target="_blank" rel="noopener noreferrer" className="break-all">
{links.repository}
</a>
</div>
+3 -2
View File
@@ -1,6 +1,7 @@
import { Link } from "@tanstack/react-router";
import { Package, Star } from "lucide-react";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Badge } from "./ui/badge";
import { getSkillBadges } from "../lib/badges";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
@@ -31,9 +32,9 @@ export function SkillListItem({ skill, ownerHandle, owner }: SkillListItemProps)
) : null}
<span className="skill-list-item-name">{skill.displayName}</span>
{badges.map((b) => (
<span key={b} className="tag tag-compact">
<Badge key={b} variant="compact">
{b}
</span>
</Badge>
))}
</div>
{skill.summary ? <p className="skill-list-item-summary">{skill.summary}</p> : null}
+9 -9
View File
@@ -9,6 +9,8 @@ import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { getRuntimeEnv } from "../lib/runtimeEnv";
import { timeAgo } from "../lib/timeAgo";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { UserBadge } from "./UserBadge";
type SkillMetadataSidebarProps = {
@@ -44,13 +46,11 @@ export function SkillMetadataSidebar({
{!nixPlugin && !isMalwareBlocked && !isRemoved ? (
<div className="sidebar-card">
<h3 className="sidebar-card-title">Download</h3>
<a
className="btn btn-primary"
href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}
style={{ width: "100%", justifyContent: "center" }}
>
Download zip
</a>
<Button asChild variant="primary" className="w-full justify-center">
<a href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}>
Download zip
</a>
</Button>
</div>
) : null}
@@ -118,9 +118,9 @@ export function SkillMetadataSidebar({
<h3 className="sidebar-card-title">Tags</h3>
<div className="sidebar-tags">
{tagEntries.map(([tag]) => (
<span key={tag} className="tag tag-compact">
<Badge key={tag} variant="compact">
{tag}
</span>
</Badge>
))}
</div>
</div>
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { Badge } from "./ui/badge";
type LlmAnalysisDimension = {
name: string;
@@ -388,9 +389,9 @@ export function SecurityScanResults({
<div className="scan-findings-title">Capability signals</div>
<div className="scan-capability-tags">
{visibleCapabilityTags.map((tag) => (
<span key={tag} className="tag scan-capability-tag">
<Badge key={tag} className="scan-capability-tag">
{SKILL_CAPABILITY_LABELS[tag] ?? tag}
</span>
</Badge>
))}
</div>
<div className="scan-capability-note">
+88 -1
View File
@@ -1,5 +1,12 @@
import { Package, Star, Download } from "lucide-react";
import { useEffect, useState } from "react";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import { convexHttp } from "../convex/client";
import { hasOwnProperty } from "../lib/hasOwnProperty";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicUser } from "../lib/publicUser";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type UserBadgeProps = {
user: PublicUser | PublicPublisher | null | undefined;
@@ -40,7 +47,14 @@ export function UserBadge({
displayName!.toLowerCase() !== handle!.toLowerCase();
const initial = (displayName ?? handle ?? "u").charAt(0).toUpperCase();
return (
// Resolve userId for stats query — PublicUser has _id directly,
// PublicPublisher has linkedUserId
const userId =
user && hasOwnProperty(user, "kind")
? (user as PublicPublisher).linkedUserId ?? null
: user?._id ?? null;
const badge = (
<span className={`user-badge user-badge-${size}`}>
{prefix ? <span className="user-badge-prefix">{prefix}</span> : null}
<span className="user-avatar" aria-hidden="true">
@@ -67,4 +81,77 @@ export function UserBadge({
)}
</span>
);
if (!userId) return badge;
return (
<Tooltip>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<UserStatsTooltipContent userId={userId} displayName={displayName} handle={handle} />
</Tooltip>
);
}
type HoverStats = { publishedSkills: number; totalStars: number; totalDownloads: number };
function UserStatsTooltipContent({
userId,
displayName,
handle,
}: {
userId: string;
displayName: string | null;
handle: string | null;
}) {
const [stats, setStats] = useState<HoverStats | null>(null);
const [fetched, setFetched] = useState(false);
// One-shot fetch on mount (tooltip content only mounts when open)
useEffect(() => {
if (fetched) return;
setFetched(true);
void convexHttp
.query(api.users.getHoverStats, { userId: userId as Id<"users"> })
.then(setStats)
.catch(() => {});
}, [userId, fetched]);
return (
<TooltipContent
side="top"
className="min-w-[140px] p-0"
onPointerDownOutside={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-space-1 px-3 py-2">
{displayName && (
<span className="text-fs-sm font-semibold text-ink truncate max-w-[180px]">
{displayName}
</span>
)}
{handle && (
<span className="text-fs-xs text-ink-soft">@{handle}</span>
)}
</div>
<div className="border-t border-line flex items-center gap-space-3 px-3 py-2">
{stats === null ? (
<span className="text-fs-xs text-ink-soft">Loading...</span>
) : (
<>
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Published skills">
<Package size={12} />
{formatCompactStat(stats.publishedSkills)}
</span>
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Stars received">
<Star size={12} />
{formatCompactStat(stats.totalStars)}
</span>
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Total downloads">
<Download size={12} />
{formatCompactStat(stats.totalDownloads)}
</span>
</>
)}
</div>
</TooltipContent>
);
}
+3 -3
View File
@@ -11,9 +11,9 @@ const Container = React.forwardRef<HTMLDivElement, ContainerProps>(
ref={ref}
className={cn(
"mx-auto w-full px-4 sm:px-6 lg:px-7",
size === "default" && "max-w-[1200px]",
size === "narrow" && "max-w-[900px]",
size === "wide" && "max-w-[1400px]",
size === "default" && "max-w-page-max",
size === "narrow" && "max-w-page-narrow",
size === "wide" && "max-w-page-max",
className,
)}
{...props}
@@ -2,7 +2,7 @@ import { Skeleton } from "../ui/skeleton";
export function DashboardSkeleton() {
return (
<div className="mx-auto max-w-[1200px] px-7 py-10">
<div className="mx-auto max-w-page-max px-7 py-10">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<Skeleton className="h-8 w-52" />
@@ -2,7 +2,7 @@ import { Skeleton } from "../ui/skeleton";
export function SkillDetailSkeleton() {
return (
<div className="mx-auto max-w-[1200px] px-7 py-10">
<div className="mx-auto max-w-page-max px-7 py-10">
{/* Breadcrumb */}
<Skeleton className="mb-6 h-4 w-48" />
+10 -31
View File
@@ -10,37 +10,16 @@ const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
<span
ref={ref}
className={cn(
// Base styles matching .tag
"inline-flex items-center gap-1.5 rounded-[var(--radius-pill)] text-[0.8rem] font-semibold",
// Variant styles
variant === "default" && [
"bg-[rgba(43,198,164,0.16)] px-3 py-1 text-[#1a6b5b]",
"dark:bg-[rgba(232,106,71,0.2)] dark:text-[#ffd0bf]",
],
variant === "accent" && [
"bg-[rgba(255,107,74,0.16)] px-3 py-1 text-[color:var(--accent-deep)]",
"dark:bg-[rgba(232,106,71,0.24)] dark:text-[#ffd0bf]",
],
variant === "compact" && [
"bg-[rgba(43,198,164,0.16)] px-2.5 py-0.5 text-[0.72rem] text-[#1a6b5b]",
"dark:bg-[rgba(232,106,71,0.2)] dark:text-[#ffd0bf]",
],
variant === "pending" && [
"bg-[rgba(240,196,106,0.2)] px-3 py-1 text-[#8a6914]",
"dark:bg-[rgba(243,201,122,0.18)] dark:text-[color:var(--gold)]",
],
variant === "success" && [
"bg-emerald-100 px-3 py-1 text-emerald-700",
"dark:bg-emerald-900/30 dark:text-emerald-300",
],
variant === "warning" && [
"bg-amber-100 px-3 py-1 text-amber-700",
"dark:bg-amber-900/30 dark:text-amber-300",
],
variant === "destructive" && [
"bg-red-100 px-3 py-1 text-red-700",
"dark:bg-red-900/30 dark:text-red-300",
],
// Base styles
"inline-flex items-center gap-1.5 rounded-[var(--radius-pill)] text-fs-sm font-semibold",
// Variant styles — all token-driven, no dark: overrides needed
variant === "default" && "bg-hover-bg px-3 py-1 text-ink-soft border border-line",
variant === "accent" && "bg-active-bg px-3 py-1 text-accent-deep border border-line",
variant === "compact" && "bg-hover-bg px-2.5 py-0.5 text-fs-xs text-ink-soft border border-line",
variant === "pending" && "bg-status-warning-bg px-3 py-1 text-status-warning-fg border border-line",
variant === "success" && "bg-status-success-bg px-3 py-1 text-status-success-fg border border-line",
variant === "warning" && "bg-status-warning-bg px-3 py-1 text-status-warning-fg border border-line",
variant === "destructive" && "bg-status-error-bg px-3 py-1 text-status-error-fg border border-line",
className,
)}
{...props}
+4 -4
View File
@@ -33,15 +33,15 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
"disabled:pointer-events-none disabled:opacity-60",
// Hover lift (matches .btn:hover)
"hover:not-disabled:-translate-y-px hover:not-disabled:shadow-[0_10px_20px_rgba(29,26,23,0.12)]",
// Hover lift
"hover:not-disabled:-translate-y-px hover:not-disabled:shadow-hover",
// Variant styles
variant === "default" &&
"border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)]",
variant === "primary" &&
"border-none bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] text-white dark:from-[#c35640] dark:to-[#953827] dark:shadow-[0_10px_22px_rgba(58,23,16,0.42),inset_0_1px_0_rgba(255,201,184,0.18)]",
"border border-accent bg-accent/10 text-[color:var(--ink)]",
variant === "destructive" &&
"border border-red-300/40 bg-red-50 text-red-700 hover:not-disabled:bg-red-100 dark:border-red-500/30 dark:bg-red-950/50 dark:text-red-300",
"border border-status-error-fg/20 bg-status-error-bg text-status-error-fg hover:not-disabled:bg-active-bg",
variant === "ghost" &&
"border-transparent bg-transparent text-[color:var(--ink-soft)] hover:not-disabled:bg-[color:var(--surface-muted)] hover:not-disabled:text-[color:var(--ink)] hover:not-disabled:shadow-none hover:not-disabled:translate-y-0",
variant === "outline" &&
+1 -1
View File
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
ref={ref}
className={cn(
// Matches .card
"flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[22px] transition-all duration-200 ease-out",
"flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-space-5 transition-all duration-200 ease-out",
className,
)}
{...props}
+2 -2
View File
@@ -16,7 +16,7 @@ const DialogOverlay = React.forwardRef<
ref={ref}
className={cn(
// Matches .report-dialog-backdrop
"fixed inset-0 z-80 grid place-items-center bg-[rgba(21,24,35,0.42)] p-5 backdrop-blur-[3px]",
"fixed inset-0 z-80 grid place-items-center bg-overlay-bg p-5 backdrop-blur-[3px]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
className,
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
ref={ref}
className={cn(
// Matches .report-dialog
"fixed top-1/2 left-1/2 z-80 grid w-[min(100%,560px)] -translate-x-1/2 -translate-y-1/2 gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5 shadow-[0_24px_50px_rgba(18,22,34,0.24)]",
"fixed top-1/2 left-1/2 z-80 grid w-[min(100%,560px)] -translate-x-1/2 -translate-y-1/2 gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5 shadow-dialog",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,
+2 -2
View File
@@ -18,7 +18,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[180px] rounded-xl border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
"z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
className,
)}
{...props}
@@ -34,7 +34,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"flex cursor-pointer select-none items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"flex cursor-pointer select-none items-center gap-2 rounded-[var(--radius-sm)] px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
+6 -10
View File
@@ -7,16 +7,12 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
ref={ref}
type={type}
className={cn(
// Base styles matching .form-input
"w-full min-h-[44px] rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)]",
"placeholder:text-[rgba(88,115,133,0.72)]",
// Focus styles matching .form-input:focus
"focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)]",
// Dark mode matching [data-theme="dark"] .form-input
"dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]",
"dark:placeholder:text-[rgba(184,205,216,0.68)]",
"dark:focus:border-[rgba(255,131,95,0.75)] dark:focus:shadow-[0_0_0_3px_rgba(255,131,95,0.2)]",
// Base styles
"w-full min-h-[44px] rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-input-border bg-input-bg",
"placeholder:text-input-placeholder",
// Focus
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
// Disabled
"disabled:cursor-not-allowed disabled:opacity-60",
className,
+2 -3
View File
@@ -10,9 +10,8 @@ const Label = React.forwardRef<
ref={ref}
className={cn(
// Matches .form-label
"text-[0.74rem] font-bold uppercase tracking-[0.14em]",
"text-[rgba(70,95,113,0.9)]",
"dark:text-[rgba(206,227,238,0.76)]",
"text-fs-xs font-bold uppercase tracking-[0.14em]",
"text-label-fg",
"peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
+7 -8
View File
@@ -14,12 +14,11 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
// Matches .form-input styling for consistency
"flex w-full min-h-[44px] items-center justify-between rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-sm text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)]",
"placeholder:text-[rgba(88,115,133,0.72)]",
"focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)]",
"dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]",
// Matches form input token styling
"flex w-full min-h-[44px] items-center justify-between rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-sm text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-input-border bg-input-bg",
"placeholder:text-input-placeholder",
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
"disabled:cursor-not-allowed disabled:opacity-60",
className,
)}
@@ -69,7 +68,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] shadow-[var(--shadow)]",
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] shadow-[var(--shadow)]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
@@ -115,7 +114,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-lg py-2 pr-8 pl-3 text-sm font-semibold outline-none transition-colors",
"relative flex w-full cursor-pointer select-none items-center rounded-[var(--radius-sm)] py-2 pr-8 pl-3 text-sm font-semibold outline-none transition-colors",
"focus:bg-[color:var(--surface-muted)] focus:text-[color:var(--ink)]",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
+1 -1
View File
@@ -15,7 +15,7 @@ const SheetOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-80 bg-[rgba(21,24,35,0.42)] backdrop-blur-[3px]",
"fixed inset-0 z-80 bg-overlay-bg backdrop-blur-[3px]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
className,
+5 -9
View File
@@ -8,16 +8,12 @@ const Textarea = React.forwardRef<
<textarea
ref={ref}
className={cn(
// Base styles matching .form-input
"w-full min-h-[100px] resize-y rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)]",
"placeholder:text-[rgba(88,115,133,0.72)]",
// Base styles
"w-full min-h-[100px] resize-y rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-input-border bg-input-bg",
"placeholder:text-input-placeholder",
// Focus
"focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)]",
// Dark mode
"dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]",
"dark:placeholder:text-[rgba(184,205,216,0.68)]",
"dark:focus:border-[rgba(255,131,95,0.75)] dark:focus:shadow-[0_0_0_3px_rgba(255,131,95,0.2)]",
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
// Disabled
"disabled:cursor-not-allowed disabled:opacity-60",
className,
+2 -2
View File
@@ -9,7 +9,7 @@ const ToggleGroup = React.forwardRef<
<ToggleGroupPrimitive.Root
ref={ref}
className={cn(
"inline-flex h-[38px] items-center gap-0.5 rounded-full border border-[color:var(--line)] bg-[color:var(--surface)] p-[3px]",
"inline-flex h-[38px] items-center gap-0.5 rounded-[var(--radius-pill)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[3px]",
className,
)}
{...props}
@@ -24,7 +24,7 @@ const ToggleGroupItem = React.forwardRef<
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
"inline-flex h-[30px] w-[30px] items-center justify-center rounded-full text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] data-[state=on]:bg-[color:var(--accent)] data-[state=on]:text-white",
"inline-flex h-[30px] w-[30px] items-center justify-center rounded-[var(--radius-pill)] text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] data-[state=on]:bg-accent data-[state=on]:text-accent-fg",
className,
)}
{...props}
+4
View File
@@ -0,0 +1,4 @@
/** Standardized icon sizes for consistent sizing across the app */
export const ICON_SM = 14;
export const ICON_MD = 16;
export const ICON_LG = 24;
+250
View File
@@ -0,0 +1,250 @@
/**
* Shared navigation configuration used by Header and Footer to eliminate
* triple duplication of nav link definitions.
*/
/** Lucide icon name used as a key to look up the component at render time. */
export type NavIconName = "wrench" | "plug" | "ghost";
export interface NavItem {
/** Visible link text */
label: string;
/** Route path passed to `<Link to>` */
to: string;
/** Optional search params object passed to `<Link search>` */
search?: Record<string, unknown>;
/** Optional lucide icon name shown beside the label in navbar tabs */
icon?: NavIconName;
/** Link only shown when user is authenticated */
authRequired: boolean;
/** Link only shown for staff / moderator users */
staffOnly: boolean;
/** Link only shown when siteMode === "souls" */
soulModeOnly: boolean;
/** Link hidden when siteMode === "souls" */
soulModeHide: boolean;
}
// ---------------------------------------------------------------------------
// Search-param shapes (kept here so Header, Footer, and mobile menu all agree)
// ---------------------------------------------------------------------------
const SKILLS_SEARCH = {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
} as const;
const SOULS_SEARCH = {
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
} as const;
const USERS_SEARCH = { q: undefined } as const;
const MANAGEMENT_SEARCH = { skill: undefined } as const;
// ---------------------------------------------------------------------------
// Primary nav items (desktop tabs row + mobile dropdown top section)
// These map to the "content-type" tabs: Skills | Plugins | Souls
// In soul-mode the order is: ClawHub (external), Souls
// In skills-mode: Skills, Plugins, Souls
// ---------------------------------------------------------------------------
export const PRIMARY_NAV_ITEMS: NavItem[] = [
{
label: "Skills",
to: "/skills",
search: SKILLS_SEARCH,
icon: "wrench",
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
},
{
label: "Plugins",
to: "/plugins",
icon: "plug",
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
},
{
label: "Souls",
to: "/souls",
search: SOULS_SEARCH,
icon: "ghost",
authRequired: false,
staffOnly: false,
soulModeOnly: false,
// In soul-mode this is the primary tab; in skills-mode it is also shown.
soulModeHide: false,
},
];
// ---------------------------------------------------------------------------
// Secondary nav items (secondary tabs row + mobile dropdown lower section)
// ---------------------------------------------------------------------------
export const SECONDARY_NAV_ITEMS: NavItem[] = [
{
label: "Users",
to: "/users",
search: USERS_SEARCH,
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
},
{
label: "About",
to: "/about",
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
},
{
label: "Stars",
to: "/stars",
authRequired: true,
staffOnly: false,
soulModeOnly: false,
soulModeHide: false,
},
{
label: "Dashboard",
to: "/dashboard",
authRequired: true,
staffOnly: false,
soulModeOnly: false,
soulModeHide: false,
},
{
label: "Management",
to: "/management",
search: MANAGEMENT_SEARCH,
authRequired: true,
staffOnly: true,
soulModeOnly: false,
soulModeHide: false,
},
];
// ---------------------------------------------------------------------------
// Footer sections
// ---------------------------------------------------------------------------
export interface FooterNavSection {
title: string;
items: FooterNavItem[];
}
export type FooterNavItem =
| { kind: "link"; label: string; to: string; search?: Record<string, unknown> }
| { kind: "external"; label: string; href: string }
| { kind: "text"; label: string };
export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
{
title: "Browse",
items: [
{ kind: "link", label: "Skills", to: "/skills", search: SKILLS_SEARCH },
{ kind: "link", label: "Plugins", to: "/plugins" },
{ kind: "link", label: "Souls", to: "/souls", search: SOULS_SEARCH },
{ kind: "link", label: "Users", to: "/users" },
{
kind: "link",
label: "Staff Picks",
to: "/skills",
search: {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: true,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
},
},
{
kind: "link",
label: "Search",
to: "/search",
search: { q: undefined, type: undefined },
},
],
},
{
title: "Publish",
items: [
{
kind: "link",
label: "Publish Skill",
to: "/publish-skill",
search: { updateSlug: undefined },
},
{
kind: "link",
label: "Publish Plugin",
to: "/publish-plugin",
search: {
ownerHandle: undefined,
name: undefined,
displayName: undefined,
family: undefined,
nextVersion: undefined,
sourceRepo: undefined,
},
},
{
kind: "external",
label: "Documentation",
href: "https://github.com/openclaw/clawhub",
},
],
},
{
title: "Community",
items: [
{ kind: "external", label: "GitHub", href: "https://github.com/openclaw/clawhub" },
{ kind: "link", label: "About", to: "/about" },
{ kind: "external", label: "OpenClaw", href: "https://openclaw.ai" },
],
},
{
title: "Platform",
items: [
{ kind: "text", label: "MIT Licensed" },
{ kind: "external", label: "Deployed on Vercel", href: "https://vercel.com" },
{ kind: "external", label: "Powered by Convex", href: "https://www.convex.dev" },
],
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Filter a nav item array based on current mode/auth/staff context. */
export function filterNavItems(
items: NavItem[],
ctx: { isSoulMode: boolean; isAuthenticated: boolean; isStaff: boolean },
): NavItem[] {
return items.filter((item) => {
if (item.soulModeOnly && !ctx.isSoulMode) return false;
if (item.soulModeHide && ctx.isSoulMode) return false;
if (item.authRequired && !ctx.isAuthenticated) return false;
if (item.staffOnly && !ctx.isStaff) return false;
return true;
});
}
+8 -1
View File
@@ -1,9 +1,16 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { SkillDetailPage } from "../../components/SkillDetailPage";
import { buildSkillMeta } from "../../lib/og";
import { fetchSkillPageData } from "../../lib/skillPage";
export const Route = createFileRoute("/$owner/$slug")({
beforeLoad: ({ params }) => {
const isHandle = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(params.owner);
const isOwnerId = params.owner.startsWith("users:") || params.owner.startsWith("publishers:");
if (!isHandle && !isOwnerId) {
throw notFound();
}
},
loader: async ({ params }) => {
const data = await fetchSkillPageData(params.slug);
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
+18
View File
@@ -92,6 +92,24 @@ export const Route = createRootRoute({
rel: "stylesheet",
href: appCss,
},
{
rel: "icon",
href: "/favicon.ico",
type: "image/x-icon",
},
{
rel: "icon",
href: "/logo.svg",
type: "image/svg+xml",
},
{
rel: "apple-touch-icon",
href: "/logo192.png",
},
{
rel: "manifest",
href: "/manifest.json",
},
],
};
},
+19 -14
View File
@@ -1,4 +1,6 @@
import { createFileRoute, Link } from '@tanstack/react-router';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { getSiteMode, getSiteName, getSiteUrlForMode } from '../lib/site';
const prohibitedCategories = [
@@ -84,9 +86,9 @@ function AboutPage() {
<main className="section about-page">
<div className="about-hero">
<div className="about-hero-copy">
<div className="skill-card-tags" style={{ marginBottom: 12 }}>
<span className="tag">About</span>
<span className="tag tag-accent">Policy</span>
<div className="skill-card-tags mb-3">
<Badge>About</Badge>
<Badge variant="accent">Policy</Badge>
</div>
<h1 className="about-title">What ClawHub will not host</h1>
<p className="about-lead">
@@ -148,17 +150,20 @@ function AboutPage() {
</div>
</div>
<div className="skill-card-tags">
<Link className="btn btn-primary" to="/skills">
Browse Skills
</Link>
<a
className="btn"
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
target="_blank"
rel="noreferrer"
>
Reviewer Doc
</a>
<Button asChild variant="primary">
<Link to="/skills">
Browse Skills
</Link>
</Button>
<Button asChild>
<a
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
target="_blank"
rel="noreferrer"
>
Reviewer Doc
</a>
</Button>
</div>
</section>
</main>
+108 -90
View File
@@ -16,6 +16,9 @@ import { useEffect, useState } from "react";
import semver from "semver";
import { api } from "../../convex/_generated/api";
import type { Doc } from "../../convex/_generated/dataModel";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { formatCompactStat } from "../lib/numberFormat";
import { familyLabel } from "../lib/packageLabels";
import type { PublicSkill } from "../lib/publicUser";
@@ -113,7 +116,7 @@ function Dashboard() {
if (!me) {
return (
<main className="section">
<div className="card">Sign in to access your dashboard.</div>
<Card>Sign in to access your dashboard.</Card>
</main>
);
}
@@ -129,31 +132,34 @@ function Dashboard() {
return (
<main className="section">
<div className="empty-state">
<h1 className="empty-state-title" style={{ fontSize: "1.4rem", fontFamily: "var(--font-display)" }}>
<h1 className="empty-state-title text-[1.4rem] font-[family-name:var(--font-display)]">
Welcome to ClawHub
</h1>
<p className="empty-state-body">
You're signed in as @{ownerHandle}. Get started by publishing your first skill or plugin.
</p>
<div style={{ display: "flex", gap: 12, justifyContent: "center" }}>
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
Publish a Skill
</Link>
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
className="btn"
>
Browse Skills
</Link>
<div className="flex gap-3 justify-center">
<Button asChild variant="primary">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
Publish a Skill
</Link>
</Button>
<Button asChild>
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
>
Browse Skills
</Link>
</Button>
</div>
</div>
</main>
@@ -164,14 +170,14 @@ function Dashboard() {
<main className="section">
<div className="dashboard-header">
<div>
<h1 className="section-title" style={{ margin: 0 }}>
<h1 className="section-title m-0">
Publisher Dashboard
</h1>
<p className="section-subtitle" style={{ margin: 0 }}>
<p className="section-subtitle m-0">
Manage your published skills and plugins.
</p>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<div className="flex gap-2 flex-wrap">
{publishers && publishers.length > 0 ? (
<select
className="input"
@@ -185,28 +191,31 @@ function Dashboard() {
))}
</select>
) : null}
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
<Upload className="h-4 w-4" aria-hidden="true" />
Publish Skill
</Link>
<Link
to="/publish-plugin"
search={{ ...emptyPluginPublishSearch, ownerHandle }}
className="btn"
>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
<Button asChild variant="primary">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Upload className="h-4 w-4" aria-hidden="true" />
Publish Skill
</Link>
</Button>
<Button asChild>
<Link
to="/publish-plugin"
search={{ ...emptyPluginPublishSearch, ownerHandle }}
>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
</Button>
</div>
</div>
<section className="card dashboard-owner-panel">
<Card className="dashboard-owner-panel">
<div className="dashboard-owner-grid">
<section className="dashboard-collection-block">
<div className="dashboard-section-header">
<div>
<h2 className="dashboard-collection-title">Publisher Skills</h2>
<p className="section-subtitle" style={{ margin: "6px 0 0" }}>
<p className="section-subtitle mt-1.5 mb-0 mx-0">
Hidden skill versions remain visible here while checks are pending.
</p>
</div>
@@ -216,10 +225,12 @@ function Dashboard() {
<div className="dashboard-inline-empty-copy">
<strong>No skills yet.</strong> Publish your first skill to share it with the community.
</div>
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
<Upload className="h-4 w-4" aria-hidden="true" />
Publish Skill
</Link>
<Button asChild variant="primary">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Upload className="h-4 w-4" aria-hidden="true" />
Publish Skill
</Link>
</Button>
</div>
) : (
<div className="dashboard-list">
@@ -240,7 +251,7 @@ function Dashboard() {
<div className="dashboard-section-header">
<div>
<h2 className="dashboard-collection-title">Publisher Plugins</h2>
<p className="section-subtitle" style={{ margin: "6px 0 0" }}>
<p className="section-subtitle mt-1.5 mb-0 mx-0">
Owner-only package view with VirusTotal, static scan, and verification state.
</p>
</div>
@@ -250,14 +261,15 @@ function Dashboard() {
<div className="dashboard-inline-empty-copy">
<strong>No plugins yet.</strong> Publish your first plugin release to validate and distribute it.
</div>
<Link
to="/publish-plugin"
search={{ ...emptyPluginPublishSearch, ownerHandle }}
className="btn btn-primary"
>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
<Button asChild variant="primary">
<Link
to="/publish-plugin"
search={{ ...emptyPluginPublishSearch, ownerHandle }}
>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
</Button>
</div>
) : (
<div className="dashboard-list">
@@ -274,7 +286,7 @@ function Dashboard() {
)}
</section>
</div>
</section>
</Card>
</main>
);
}
@@ -293,10 +305,10 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
</Link>
<span className="dashboard-list-id">/{skill.slug}</span>
{skill.pendingReview ? (
<span className="tag tag-pending">
<Badge variant="pending">
<Clock className="h-3 w-3" aria-hidden="true" />
Pending checks
</span>
</Badge>
) : null}
</div>
<div className="dashboard-inline-metrics">
@@ -328,17 +340,20 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
)}
</div>
<div className="dashboard-row-actions">
<Link to="/publish-skill" search={{ updateSlug: skill.slug }} className="btn btn-sm">
<Upload className="h-3 w-3" aria-hidden="true" />
New Version
</Link>
<Link
to="/$owner/$slug"
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
className="btn btn-ghost btn-sm"
>
View
</Link>
<Button asChild size="sm">
<Link to="/publish-skill" search={{ updateSlug: skill.slug }}>
<Upload className="h-3 w-3" aria-hidden="true" />
New Version
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link
to="/$owner/$slug"
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
>
View
</Link>
</Button>
</div>
</div>
);
@@ -376,17 +391,17 @@ function PackageStatusTag({
label: string;
tone: "default" | "pending" | "warning" | "danger" | "success";
}) {
const className =
const variant =
tone === "pending"
? "tag tag-pending"
? "pending"
: tone === "warning"
? "tag dashboard-tag-warning"
? "warning"
: tone === "danger"
? "tag dashboard-tag-danger"
? "destructive"
: tone === "success"
? "tag dashboard-tag-success"
: "tag";
return <span className={className}>{label}</span>;
? "success"
: "default";
return <Badge variant={variant}>{label}</Badge>;
}
function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle: string }) {
@@ -480,24 +495,27 @@ function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle:
</span>
</div>
<div className="dashboard-row-actions">
<Link
to="/publish-plugin"
search={{
ownerHandle,
name: pkg.name,
displayName: pkg.displayName,
family: pkg.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
nextVersion: nextVersion ?? undefined,
sourceRepo: pkg.sourceRepo ?? undefined,
}}
className="btn btn-sm"
>
<Upload className="h-3 w-3" aria-hidden="true" />
New Release
</Link>
<Link to="/plugins/$name" params={{ name: pkg.name }} className="btn btn-ghost btn-sm">
View
</Link>
<Button asChild size="sm">
<Link
to="/publish-plugin"
search={{
ownerHandle,
name: pkg.name,
displayName: pkg.displayName,
family: pkg.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
nextVersion: nextVersion ?? undefined,
sourceRepo: pkg.sourceRepo ?? undefined,
}}
>
<Upload className="h-3 w-3" aria-hidden="true" />
New Release
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link to="/plugins/$name" params={{ name: pkg.name }}>
View
</Link>
</Button>
</div>
</div>
);
+29 -25
View File
@@ -7,6 +7,8 @@ import { SkillListItem } from "../components/SkillListItem";
import { SkillStatsTripletLine } from "../components/SkillStats";
import { SoulCard } from "../components/SoulCard";
import { SoulStatsTripletLine } from "../components/SoulStats";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { UserBadge } from "../components/UserBadge";
import { convexHttp } from "../convex/client";
import { getSkillBadges } from "../lib/badges";
@@ -83,28 +85,30 @@ function SkillsHome() {
: "Public skill bundles, plugin packages, and builder profiles in one shared index. Browse fast, fork the good stuff, ship your own."}
</p>
<div className="home-hero-actions">
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
className="btn btn-primary"
>
Browse All Skills & Plugins
</Link>
<Link
to="/publish-skill"
search={{ updateSlug: undefined }}
className="btn home-hero-publish-btn"
>
+ Publish Yours
</Link>
<Button asChild variant="primary">
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
>
Browse All Skills & Plugins
</Link>
</Button>
<Button asChild className="home-hero-publish-btn">
<Link
to="/publish-skill"
search={{ updateSlug: undefined }}
>
+ Publish Yours
</Link>
</Button>
</div>
<p className="home-hero-explainer">
Sharp filters. Clean listings. Discovery that feels more like a real index and less
@@ -369,9 +373,9 @@ function OnlyCrabsHome() {
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<button className="btn btn-primary" type="submit">
<Button variant="primary" type="submit">
Search
</button>
</Button>
</form>
</div>
</div>
@@ -397,7 +401,7 @@ function OnlyCrabsHome() {
</div>
<div className="grid">
{latest.length === 0 ? (
<div className="card">No souls yet. Be the first.</div>
<Card>No souls yet. Be the first.</Card>
) : (
latest.map((soul) => (
<SoulCard
+144 -138
View File
@@ -3,6 +3,9 @@ import { useMutation, useQuery } from "convex/react";
import { useEffect, useState } from "react";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import {
getSkillBadges,
isSkillDeprecated,
@@ -176,7 +179,7 @@ function Management() {
if (!staff) {
return (
<main className="section">
<div className="card">Management only.</div>
<Card>Management only.</Card>
</main>
);
}
@@ -184,7 +187,7 @@ function Management() {
if (!recentVersions || !reportedSkills || !duplicateCandidates) {
return (
<main className="section">
<div className="card">Loading management console</div>
<Card>Loading management console</Card>
</main>
);
}
@@ -259,8 +262,8 @@ function Management() {
<h1 className="section-title">Management console</h1>
<p className="section-subtitle">Moderation, curation, and ownership tools.</p>
<div className="card">
<h2 className="section-title" style={{ fontSize: "1.2rem", margin: 0 }}>
<Card>
<h2 className="section-title text-[1.2rem] m-0">
Reported skills
</h2>
<div className="management-controls">
@@ -292,7 +295,7 @@ function Management() {
<Link to="/$owner/$slug" params={{ owner: ownerParam, slug: skill.slug }}>
{skill.displayName}
</Link>
<div className="section-subtitle" style={{ margin: 0 }}>
<div className="section-subtitle m-0">
@{owner?.handle ?? owner?.name ?? "user"} · v{latestVersion?.version ?? "—"} ·
{skill.reportCount ?? 0} report{(skill.reportCount ?? 0) === 1 ? "" : "s"}
{skill.lastReportedAt
@@ -315,17 +318,18 @@ function Management() {
))}
</div>
) : (
<div className="section-subtitle" style={{ margin: 0 }}>
<div className="section-subtitle m-0">
No report reasons yet.
</div>
)}
</div>
<div className="management-actions">
<Link className="btn" to="/management" search={{ skill: skill.slug }}>
Manage
</Link>
<button
className="btn"
<Button asChild>
<Link to="/management" search={{ skill: skill.slug }}>
Manage
</Link>
</Button>
<Button
type="button"
onClick={() => {
const reason = window.prompt(
@@ -340,10 +344,9 @@ function Management() {
}}
>
{skill.softDeletedAt ? "Restore" : "Hide"}
</button>
</Button>
{admin ? (
<button
className="btn"
<Button
type="button"
onClick={() => {
if (!window.confirm(`Hard delete ${skill.displayName}?`)) return;
@@ -351,7 +354,7 @@ function Management() {
}}
>
Hard delete
</button>
</Button>
) : null}
</div>
</div>
@@ -359,14 +362,14 @@ function Management() {
})
)}
</div>
</div>
</Card>
<div className="card" style={{ marginTop: 20 }}>
<h2 className="section-title" style={{ fontSize: "1.2rem", margin: 0 }}>
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">
Skill tools
</h2>
{selectedSlug ? (
<div className="section-subtitle" style={{ marginTop: 8 }}>
<div className="section-subtitle mt-2">
Managing "{selectedSlug}" ·{" "}
<Link to="/management" search={{ skill: undefined }}>
Clear selection
@@ -406,7 +409,7 @@ function Management() {
<Link to="/$owner/$slug" params={{ owner: ownerParam, slug: skill.slug }}>
{skill.displayName}
</Link>
<div className="section-subtitle" style={{ margin: 0 }}>
<div className="section-subtitle m-0">
@{owner?.handle ?? owner?.name ?? "user"} · v{latestVersion?.version ?? "—"} ·
updated {formatTimestamp(skill.updatedAt)} · {moderationStatus}
{badges.length ? ` · ${badges.join(", ").toLowerCase()}` : ""}
@@ -414,14 +417,14 @@ function Management() {
{skill.moderationFlags?.length ? (
<div className="management-tags">
{skill.moderationFlags.map((flag: string) => (
<span key={flag} className="tag">
<Badge key={flag}>
{flag}
</span>
</Badge>
))}
</div>
) : null}
<div className="management-sublist">
<div className="section-subtitle" style={{ margin: 0 }}>
<div className="section-subtitle m-0">
Manual overrides
</div>
<section className="management-override-panel">
@@ -453,29 +456,29 @@ function Management() {
onChange={(event) => setSkillOverrideNote(event.target.value)}
/>
<div className="management-actions management-actions-start">
<button
className="btn management-action-btn"
<Button
className="management-action-btn"
type="button"
disabled={!skillOverrideNote.trim()}
onClick={applySkillOverride}
>
{skill.manualOverride ? "Update okay override" : "Mark skill okay"}
</button>
</Button>
{skill.manualOverride ? (
<button
className="btn management-action-btn"
<Button
className="management-action-btn"
type="button"
disabled={!skillOverrideNote.trim()}
onClick={clearSkillOverride}
>
Clear skill override
</button>
</Button>
) : null}
</div>
</section>
</div>
<div className="management-sublist">
<div className="section-subtitle" style={{ margin: 0 }}>
<div className="section-subtitle m-0">
Recent audit activity
</div>
<section className="management-override-panel management-audit-panel">
@@ -484,7 +487,7 @@ function Management() {
<span>Last {SKILL_AUDIT_LOG_LIMIT} entries for this skill.</span>
</div>
{auditLogs.length === 0 ? (
<div className="section-subtitle" style={{ margin: 0 }}>
<div className="section-subtitle m-0">
No audit activity yet.
</div>
) : (
@@ -537,8 +540,8 @@ function Management() {
</label>
<div className="management-control management-control-stack">
<span className="mono">duplicate action</span>
<button
className="btn management-action-btn"
<Button
className="management-action-btn"
type="button"
onClick={() =>
void setDuplicate({
@@ -548,7 +551,7 @@ function Management() {
}
>
Set duplicate
</button>
</Button>
</div>
{admin ? (
<>
@@ -568,8 +571,8 @@ function Management() {
</label>
<div className="management-control management-control-stack">
<span className="mono">owner action</span>
<button
className="btn management-action-btn"
<Button
className="management-action-btn"
type="button"
onClick={() =>
void changeOwner({
@@ -579,22 +582,23 @@ function Management() {
}
>
Change owner
</button>
</Button>
</div>
</>
) : null}
</div>
</div>
<div className="management-actions management-action-grid">
<Link
className="btn management-action-btn"
to="/$owner/$slug"
params={{ owner: ownerParam, slug: skill.slug }}
>
View
</Link>
<button
className="btn management-action-btn"
<Button asChild className="management-action-btn">
<Link
to="/$owner/$slug"
params={{ owner: ownerParam, slug: skill.slug }}
>
View
</Link>
</Button>
<Button
className="management-action-btn"
type="button"
onClick={() => {
const reason = window.prompt(
@@ -609,9 +613,9 @@ function Management() {
}}
>
{skill.softDeletedAt ? "Restore" : "Hide"}
</button>
<button
className="btn management-action-btn"
</Button>
<Button
className="management-action-btn"
type="button"
onClick={() =>
void setBatch({
@@ -621,10 +625,10 @@ function Management() {
}
>
{isHighlighted ? "Unhighlight" : "Highlight"}
</button>
</Button>
{admin ? (
<button
className="btn management-action-btn"
<Button
className="management-action-btn"
type="button"
onClick={() => {
if (!window.confirm(`Hard delete ${skill.displayName}?`)) return;
@@ -632,11 +636,11 @@ function Management() {
}}
>
Hard delete
</button>
</Button>
) : null}
{staff ? (
<button
className="btn management-action-btn"
<Button
className="management-action-btn"
type="button"
disabled={!canBanOwner}
onClick={() => {
@@ -650,12 +654,12 @@ function Management() {
}}
>
Ban user
</button>
</Button>
) : null}
{admin ? (
<>
<button
className="btn management-action-btn"
<Button
className="management-action-btn"
type="button"
onClick={() =>
void setOfficialBadge({
@@ -665,9 +669,9 @@ function Management() {
}
>
{isOfficial ? "Remove official" : "Mark official"}
</button>
<button
className="btn management-action-btn"
</Button>
<Button
className="management-action-btn"
type="button"
onClick={() =>
void setDeprecatedBadge({
@@ -677,7 +681,7 @@ function Management() {
}
>
{isDeprecated ? "Remove deprecated" : "Mark deprecated"}
</button>
</Button>
</>
) : null}
</div>
@@ -686,10 +690,10 @@ function Management() {
})()
)}
</div>
</div>
</Card>
<div className="card" style={{ marginTop: 20 }}>
<h2 className="section-title" style={{ fontSize: "1.2rem", margin: 0 }}>
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">
Duplicate candidates
</h2>
<div className="management-list">
@@ -711,7 +715,7 @@ function Management() {
>
{entry.skill.displayName}
</Link>
<div className="section-subtitle" style={{ margin: 0 }}>
<div className="section-subtitle m-0">
@{entry.owner?.handle ?? entry.owner?.name ?? "user"} · v
{entry.latestVersion?.version ?? "—"} · fingerprint{" "}
{entry.fingerprint?.slice(0, 8)}
@@ -721,27 +725,27 @@ function Management() {
<div key={match.skill._id} className="management-subitem">
<div>
<strong>{match.skill.displayName}</strong>
<div className="section-subtitle" style={{ margin: 0 }}>
<div className="section-subtitle m-0">
@{match.owner?.handle ?? match.owner?.name ?? "user"} ·{" "}
{match.skill.slug}
</div>
</div>
<div className="management-actions">
<Link
className="btn"
to="/$owner/$slug"
params={{
owner: resolveOwnerParam(
match.owner?.handle ?? null,
match.owner?._id ?? match.skill.ownerUserId,
),
slug: match.skill.slug,
}}
>
View
</Link>
<button
className="btn"
<Button asChild>
<Link
to="/$owner/$slug"
params={{
owner: resolveOwnerParam(
match.owner?.handle ?? null,
match.owner?._id ?? match.skill.ownerUserId,
),
slug: match.skill.slug,
}}
>
View
</Link>
</Button>
<Button
type="button"
onClick={() =>
void setDuplicate({
@@ -751,58 +755,15 @@ function Management() {
}
>
Mark duplicate
</button>
</Button>
</div>
</div>
))}
</div>
</div>
<div className="management-actions">
<Link
className="btn"
to="/$owner/$slug"
params={{
owner: resolveOwnerParam(
entry.owner?.handle ?? null,
entry.owner?._id ?? entry.skill.ownerUserId,
),
slug: entry.skill.slug,
}}
>
View
</Link>
</div>
</div>
))
)}
</div>
</div>
<div className="card" style={{ marginTop: 20 }}>
<h2 className="section-title" style={{ fontSize: "1.2rem", margin: 0 }}>
Recent pushes
</h2>
<div className="management-list">
{recentVersions.length === 0 ? (
<div className="stat">No recent versions.</div>
) : (
recentVersions.map((entry) => (
<div key={entry.version._id} className="management-item">
<div className="management-item-main">
<strong>{entry.skill?.displayName ?? "Unknown skill"}</strong>
<div className="section-subtitle" style={{ margin: 0 }}>
v{entry.version.version} · @{entry.owner?.handle ?? entry.owner?.name ?? "user"}
</div>
</div>
<div className="management-actions">
{entry.skill ? (
<Link className="btn" to="/management" search={{ skill: entry.skill.slug }}>
Manage
</Link>
) : null}
{entry.skill ? (
<Button asChild>
<Link
className="btn"
to="/$owner/$slug"
params={{
owner: resolveOwnerParam(
@@ -814,17 +775,64 @@ function Management() {
>
View
</Link>
</Button>
</div>
</div>
))
)}
</div>
</Card>
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">
Recent pushes
</h2>
<div className="management-list">
{recentVersions.length === 0 ? (
<div className="stat">No recent versions.</div>
) : (
recentVersions.map((entry) => (
<div key={entry.version._id} className="management-item">
<div className="management-item-main">
<strong>{entry.skill?.displayName ?? "Unknown skill"}</strong>
<div className="section-subtitle m-0">
v{entry.version.version} · @{entry.owner?.handle ?? entry.owner?.name ?? "user"}
</div>
</div>
<div className="management-actions">
{entry.skill ? (
<Button asChild>
<Link to="/management" search={{ skill: entry.skill.slug }}>
Manage
</Link>
</Button>
) : null}
{entry.skill ? (
<Button asChild>
<Link
to="/$owner/$slug"
params={{
owner: resolveOwnerParam(
entry.owner?.handle ?? null,
entry.owner?._id ?? entry.skill.ownerUserId,
),
slug: entry.skill.slug,
}}
>
View
</Link>
</Button>
) : null}
</div>
</div>
))
)}
</div>
</div>
</Card>
{admin ? (
<div className="card" style={{ marginTop: 20 }}>
<h2 className="section-title" style={{ fontSize: "1.2rem", margin: 0 }}>
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">
Users
</h2>
<div className="management-controls">
@@ -848,7 +856,7 @@ function Management() {
<div className="management-item-main">
<span className="mono">@{user.handle ?? user.name ?? "user"}</span>
{user.deletedAt || user.deactivatedAt ? (
<div className="section-subtitle" style={{ margin: 0 }}>
<div className="section-subtitle m-0">
{user.banReason && user.deletedAt
? `Banned ${formatTimestamp(user.deletedAt)} · ${user.banReason}`
: `Deleted ${formatTimestamp((user.deactivatedAt ?? user.deletedAt) as number)}`}
@@ -869,8 +877,7 @@ function Management() {
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
<button
className="btn"
<Button
type="button"
disabled={user._id === me?._id}
onClick={() => {
@@ -891,10 +898,9 @@ function Management() {
}}
>
Ban user
</button>
</Button>
{user.deletedAt && !user.deactivatedAt ? (
<button
className="btn"
<Button
type="button"
onClick={() => {
const label = `@${user.handle ?? user.name ?? "user"}`;
@@ -909,14 +915,14 @@ function Management() {
}}
>
Unban user
</button>
</Button>
) : null}
</div>
</div>
))
)}
</div>
</div>
</Card>
) : null}
</main>
);
+7 -10
View File
@@ -296,8 +296,7 @@ function PluginDetailRoute() {
: [];
return (
<main className="py-10">
<Container>
<main className="section">
<div className="flex flex-col gap-5">
{/* Header card */}
<Card>
@@ -373,13 +372,12 @@ function PluginDetailRoute() {
<span className="text-sm">
Latest release: <strong>v{pkg.latestVersion}</strong>
</span>
<a
href={getPackageDownloadPath(name, pkg.latestVersion)}
className="inline-flex min-h-[34px] w-full items-center justify-center gap-2 rounded-[var(--radius-pill)] border border-[color:var(--border-ui)] bg-transparent px-3 py-1.5 text-xs font-semibold text-[color:var(--ink)] transition-all duration-200 no-underline hover:border-[color:var(--border-ui-hover)] hover:bg-[color:var(--surface)] sm:w-auto sm:whitespace-nowrap"
>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
Download zip
</a>
<Button asChild variant="outline" size="sm" className="w-full no-underline sm:w-auto">
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
Download zip
</a>
</Button>
</div>
) : null}
</CardContent>
@@ -579,7 +577,6 @@ function PluginDetailRoute() {
</Card>
) : null}
</div>
</Container>
</main>
);
}
+23 -22
View File
@@ -3,6 +3,7 @@ import { AlertTriangle, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { BrowseSidebar } from "../../components/BrowseSidebar";
import { PluginListItem } from "../../components/PluginListItem";
import { Button } from "../../components/ui/button";
import {
fetchPluginCatalog,
isRateLimitedPackageApiError,
@@ -143,7 +144,7 @@ export function PluginsIndex() {
<main className="browse-page">
<div className="browse-page-header">
<h1 className="browse-title">Plugins</h1>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<div className="flex items-center gap-2">
<button
className="browse-sidebar-toggle"
type="button"
@@ -152,20 +153,21 @@ export function PluginsIndex() {
>
Filters
</button>
<Link
className="btn btn-primary"
to="/publish-plugin"
search={{
ownerHandle: undefined,
name: undefined,
displayName: undefined,
family: undefined,
nextVersion: undefined,
sourceRepo: undefined,
}}
>
Publish
</Link>
<Button asChild variant="primary">
<Link
to="/publish-plugin"
search={{
ownerHandle: undefined,
name: undefined,
displayName: undefined,
family: undefined,
nextVersion: undefined,
sourceRepo: undefined,
}}
>
Publish
</Link>
</Button>
</div>
</div>
<form className="browse-page-search" onSubmit={handleSearch}>
@@ -221,10 +223,9 @@ export function PluginsIndex() {
)}
{!search.q && (search.cursor || nextCursor) ? (
<div style={{ display: "flex", gap: 12, justifyContent: "center", marginTop: 22 }}>
<div className="mt-5 flex justify-center gap-3">
{search.cursor ? (
<button
className="btn"
<Button
type="button"
onClick={() => {
void navigate({
@@ -233,11 +234,11 @@ export function PluginsIndex() {
}}
>
First page
</button>
</Button>
) : null}
{nextCursor ? (
<button
className="btn btn-primary"
<Button
variant="primary"
type="button"
onClick={() => {
void navigate({
@@ -246,7 +247,7 @@ export function PluginsIndex() {
}}
>
Next page
</button>
</Button>
) : null}
</div>
) : null}
+12 -11
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { PluginListItem } from "../components/PluginListItem";
import { SkillListItem } from "../components/SkillListItem";
import { UserListItem } from "../components/UserListItem";
import { Card } from "../components/ui/card";
import type { PublicSkill, PublicUser } from "../lib/publicUser";
import {
useUnifiedSearch,
@@ -57,10 +58,10 @@ function UnifiedSearchPage() {
return (
<main className="browse-page">
<h1 className="browse-title" style={{ marginBottom: 16 }}>
<h1 className="browse-title mb-4">
{search.q ? (
<>
Search results for <span style={{ color: "var(--accent)" }}>"{search.q}"</span>
Search results for <span className="text-[color:var(--accent)]">"{search.q}"</span>
</>
) : (
"Search"
@@ -68,7 +69,7 @@ function UnifiedSearchPage() {
</h1>
<form className="search-page-form" onSubmit={handleSearch}>
<div className="browse-search-bar" style={{ maxWidth: 560, flex: 1 }}>
<div className="browse-search-bar max-w-[560px] flex-1">
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="browse-search-input"
@@ -120,19 +121,19 @@ function UnifiedSearchPage() {
</div>
{isSearching ? (
<div className="card">
<Card>
<div className="loading-indicator">Searching...</div>
</div>
</Card>
) : !search.q ? (
<div className="card" style={{ textAlign: "center", padding: 40 }}>
<p style={{ color: "var(--ink-soft)" }}>
<Card className="text-center p-10">
<p className="text-ink-soft">
Enter a search term to find skills, plugins, and users
</p>
</div>
</Card>
) : results.length === 0 ? (
<div className="card" style={{ textAlign: "center", padding: 40 }}>
<p style={{ color: "var(--ink-soft)" }}>No results found for "{search.q}"</p>
</div>
<Card className="text-center p-10">
<p className="text-ink-soft">No results found for "{search.q}"</p>
</Card>
) : (
<div className="results-list">
{results.map((item) =>
+4 -4
View File
@@ -3,6 +3,7 @@ import { SkillCard } from "../../components/SkillCard";
import { SkillListItem } from "../../components/SkillListItem";
import { getPlatformLabels } from "../../components/skillDetailUtils";
import { SkillStatsTripletLine } from "../../components/SkillStats";
import { Button } from "../../components/ui/button";
import { UserBadge } from "../../components/UserBadge";
import { getSkillBadges } from "../../lib/badges";
import { buildSkillHref, type SkillListEntry } from "./-types";
@@ -109,8 +110,7 @@ export function SkillsResults({
{canLoadMore || isLoadingMore ? (
<div
ref={canAutoLoad ? loadMoreRef : null}
className="card"
style={{ marginTop: 16, display: "flex", justifyContent: "center" }}
className="card mt-4 flex justify-center"
>
{canAutoLoad ? (
isLoadingMore ? (
@@ -119,9 +119,9 @@ export function SkillsResults({
"Scroll to load more"
)
) : (
<button className="btn" type="button" onClick={loadMore} disabled={isLoadingMore}>
<Button type="button" onClick={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? "Loading..." : "Load more"}
</button>
</Button>
)}
</div>
) : null}
+2 -2
View File
@@ -219,7 +219,7 @@ export function SkillsToolbar({
onClick={view === "list" ? onToggleView : undefined}
className={`inline-flex h-[30px] w-[30px] items-center justify-center rounded-full transition-colors ${
view === "cards"
? "bg-[color:var(--accent)] text-white"
? "bg-accent text-accent-fg"
: "text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
aria-label="Grid view"
@@ -231,7 +231,7 @@ export function SkillsToolbar({
onClick={view === "cards" ? onToggleView : undefined}
className={`inline-flex h-[30px] w-[30px] items-center justify-center rounded-full transition-colors ${
view === "list"
? "bg-[color:var(--accent)] text-white"
? "bg-accent text-accent-fg"
: "text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
aria-label="List view"
+16 -10
View File
@@ -1,4 +1,6 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
const sortKeys = ["newest", "downloads", "stars", "name", "updated"] as const;
type SortKey = (typeof sortKeys)[number];
@@ -26,9 +28,9 @@ function SoulsHoldingPage() {
<main className="section souls-coming-page">
<section className="souls-coming-hero">
<div>
<div className="skill-card-tags" style={{ marginBottom: 12 }}>
<span className="tag">Souls</span>
<span className="tag tag-accent">Coming soon</span>
<div className="skill-card-tags mb-3">
<Badge>Souls</Badge>
<Badge variant="accent">Coming soon</Badge>
</div>
<h1 className="about-title">SOUL.md discovery is on deck</h1>
<p className="about-lead">
@@ -55,18 +57,22 @@ function SoulsHoldingPage() {
<section className="about-enforcement">
<div>
<span className="about-callout-label">In the meantime</span>
<p className="about-lead" style={{ marginBottom: 0 }}>
<p className="about-lead mb-0">
ClawHub already handles skills and plugins. Souls will get the same discovery treatment
once the publishing flow is ready.
</p>
</div>
<div className="skill-card-tags">
<Link className="btn btn-primary" to="/skills" search={{ q: undefined, sort: "downloads", dir: "desc", highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}>
Browse Skills
</Link>
<Link className="btn" to="/users" search={{ q: undefined }}>
Browse Users
</Link>
<Button asChild variant="primary">
<Link to="/skills" search={{ q: undefined, sort: "downloads", dir: "desc", highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}>
Browse Skills
</Link>
</Button>
<Button asChild>
<Link to="/users" search={{ q: undefined }}>
Browse Users
</Link>
</Button>
</div>
</section>
</main>
+35 -33
View File
@@ -4,6 +4,9 @@ import { useEffect, useState } from "react";
import { api } from "../../../convex/_generated/api";
import type { Doc } from "../../../convex/_generated/dataModel";
import { SkillListItem } from "../../components/SkillListItem";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import type { PublicSkill, PublicUser } from "../../lib/publicUser";
export const Route = createFileRoute("/u/$handle")({
@@ -38,9 +41,9 @@ function UserProfile() {
if (user === undefined) {
return (
<main className="section">
<div className="card">
<Card>
<div className="loading-indicator">Loading user</div>
</div>
</Card>
</main>
);
}
@@ -48,7 +51,7 @@ function UserProfile() {
if (user === null) {
return (
<main className="section">
<div className="card">User not found.</div>
<Card>User not found.</Card>
</main>
);
}
@@ -111,15 +114,15 @@ function UserProfile() {
<>
{published.length > 0 ? (
<>
<h2 className="home-section-title" style={{ marginBottom: 12 }}>
<h2 className="home-section-title mb-3">
Published ({published.length})
</h2>
{isLoadingPublished ? (
<div className="card">
<Card>
<div className="loading-indicator">Loading published skills...</div>
</div>
</Card>
) : (
<div className="results-list" style={{ marginBottom: 24 }}>
<div className="results-list mb-6">
{published.map((skill) => (
<SkillListItem key={skill._id} skill={skill} />
))}
@@ -128,15 +131,15 @@ function UserProfile() {
</>
) : null}
<h2 className="home-section-title" style={{ marginBottom: 12 }}>
<h2 className="home-section-title mb-3">
Stars ({skills.length})
</h2>
{isLoadingSkills ? (
<div className="card">
<Card>
<div className="loading-indicator">Loading stars...</div>
</div>
</Card>
) : skills.length === 0 ? (
<div className="card">No stars yet.</div>
<Card>No stars yet.</Card>
) : (
<div className="results-list">
{skills.map((skill) => (
@@ -161,12 +164,12 @@ function InstalledSection(props: {
if (data === undefined) {
return (
<>
<h2 className="section-title" style={{ fontSize: "1.3rem" }}>
<h2 className="section-title text-xl">
Installed
</h2>
<div className="card">
<Card>
<div className="loading-indicator">Loading telemetry</div>
</div>
</Card>
</>
);
}
@@ -174,32 +177,31 @@ function InstalledSection(props: {
if (data === null) {
return (
<>
<h2 className="section-title" style={{ fontSize: "1.3rem" }}>
<h2 className="section-title text-xl">
Installed
</h2>
<div className="card">Sign in to view your installed skills.</div>
<Card>Sign in to view your installed skills.</Card>
</>
);
}
return (
<>
<h2 className="section-title" style={{ fontSize: "1.3rem" }}>
<h2 className="section-title text-xl">
Installed
</h2>
<p className="section-subtitle" style={{ maxWidth: 760 }}>
<p className="section-subtitle max-w-[760px]">
Private view. Only you can see your folders/roots. Everyone else only sees aggregated
install counts per skill.
</p>
<div className="profile-actions">
<button className="btn" type="button" onClick={props.onToggleRemoved}>
<Button type="button" onClick={props.onToggleRemoved}>
{props.includeRemoved ? "Hide removed" : "Show removed"}
</button>
<button className="btn" type="button" onClick={() => setShowRaw((value) => !value)}>
</Button>
<Button type="button" onClick={() => setShowRaw((value) => !value)}>
{showRaw ? "Hide JSON" : "Show JSON"}
</button>
<button
className="btn"
</Button>
<Button
type="button"
onClick={() => {
if (!window.confirm("Delete all telemetry data?")) return;
@@ -207,23 +209,23 @@ function InstalledSection(props: {
}}
>
Delete telemetry
</button>
</Button>
</div>
{showRaw ? (
<div className="card telemetry-json" style={{ marginBottom: 18 }}>
<pre className="mono" style={{ margin: 0, whiteSpace: "pre-wrap" }}>
<Card className="telemetry-json mb-4">
<pre className="mono m-0 whitespace-pre-wrap">
{JSON.stringify(data, null, 2)}
</pre>
</div>
</Card>
) : null}
{data.roots.length === 0 ? (
<div className="card">No telemetry yet. Run `clawhub sync` from the CLI.</div>
<Card>No telemetry yet. Run `clawhub sync` from the CLI.</Card>
) : (
<div style={{ display: "grid", gap: 16 }}>
<div className="grid gap-4">
{data.roots.map((root) => (
<div key={root.rootId} className="card telemetry-root">
<Card key={root.rootId} className="telemetry-root">
<div className="telemetry-root-header">
<div>
<div className="telemetry-root-title">{root.label}</div>
@@ -232,7 +234,7 @@ function InstalledSection(props: {
{root.expiredAt ? " · stale" : ""}
</div>
</div>
<div className="tag">{root.skills.length} skills</div>
<Badge>{root.skills.length} skills</Badge>
</div>
{root.skills.length === 0 ? (
<div className="stat">No skills found in this root.</div>
@@ -255,7 +257,7 @@ function InstalledSection(props: {
))}
</div>
)}
</div>
</Card>
))}
</div>
)}
+3 -2
View File
@@ -3,6 +3,7 @@ import { Search } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { api } from "../../../convex/_generated/api";
import { UserListItem } from "../../components/UserListItem";
import { Card } from "../../components/ui/card";
import { convexHttp } from "../../convex/client";
import type { PublicUser } from "../../lib/publicUser";
@@ -84,9 +85,9 @@ function UsersIndex() {
</div>
{loading ? (
<div className="card">
<Card>
<div className="loading-indicator">Loading users...</div>
</div>
</Card>
) : users.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">No users found</p>
+161 -28
View File
@@ -4,6 +4,7 @@
@import "@fontsource/ibm-plex-mono/latin.css";
@theme {
/* Colors — core palette */
--color-bg: var(--bg);
--color-bg-soft: var(--bg-soft);
--color-surface: var(--surface);
@@ -12,6 +13,7 @@
--color-ink: var(--ink);
--color-ink-soft: var(--ink-soft);
--color-accent: var(--accent);
--color-accent-fg: var(--accent-fg);
--color-accent-deep: var(--accent-deep);
--color-seafoam: var(--seafoam);
--color-gold: var(--gold);
@@ -19,14 +21,70 @@
--color-border-ui: var(--border-ui);
--color-border-ui-hover: var(--border-ui-hover);
--color-border-ui-active: var(--border-ui-active);
--radius-lg: var(--radius-lg);
--radius-md: var(--radius-md);
--radius-sm: var(--radius-sm);
--radius-pill: var(--radius-pill);
/* Colors — status */
--color-status-success-bg: var(--status-success-bg);
--color-status-success-fg: var(--status-success-fg);
--color-status-warning-bg: var(--status-warning-bg);
--color-status-warning-fg: var(--status-warning-fg);
--color-status-error-bg: var(--status-error-bg);
--color-status-error-fg: var(--status-error-fg);
/* Colors — form controls */
--color-input-border: var(--input-border);
--color-input-bg: var(--input-bg);
--color-input-placeholder: var(--input-placeholder);
--color-input-focus-border: var(--input-focus-border);
--color-input-focus-ring: var(--input-focus-ring);
--color-label-fg: var(--label-fg);
/* Colors — interactive states */
--color-hover-bg: var(--hover-bg);
--color-active-bg: var(--active-bg);
/* Colors — overlay */
--color-overlay-bg: var(--overlay-bg);
/* Spacing scale */
--spacing-space-1: var(--space-1);
--spacing-space-2: var(--space-2);
--spacing-space-3: var(--space-3);
--spacing-space-4: var(--space-4);
--spacing-space-5: var(--space-5);
--spacing-space-6: var(--space-6);
--spacing-space-7: var(--space-7);
--spacing-space-8: var(--space-8);
/* Typography scale */
--font-size-fs-xs: var(--fs-xs);
--font-size-fs-sm: var(--fs-sm);
--font-size-fs-base: var(--fs-base);
--font-size-fs-md: var(--fs-md);
--font-size-fs-lg: var(--fs-lg);
--font-size-fs-xl: var(--fs-xl);
--font-size-fs-2xl: var(--fs-2xl);
--font-size-fs-3xl: var(--fs-3xl);
/* Radii */
--radius-lg: var(--r-lg);
--radius-md: var(--r-md);
--radius-sm: var(--r-sm);
--radius-pill: var(--r-pill);
/* Fonts */
--font-display: var(--font-display);
--font-body: var(--font-body);
--font-mono: var(--font-mono);
/* Layout widths */
--width-page-max: var(--page-max);
--width-page-narrow: var(--page-narrow);
--width-page-min: var(--page-min);
/* Shadows */
--shadow-default: var(--shadow);
--shadow-dialog: var(--shadow-dialog);
--shadow-hover: var(--shadow-hover);
}
@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
@@ -43,23 +101,56 @@
--surface-muted: #1a1a1a;
--nav-bg: rgba(10, 10, 10, 0.95);
--ink: #e0e0e0;
--ink-soft: #808080;
--ink-soft: #818181;
--accent: #ffffff;
--accent-fg: #0a0a0a;
--accent-deep: #cccccc;
--seafoam: #808080;
--seafoam: #818181;
--diff-added: #4a9;
--diff-added-strong: #3a8;
--diff-removed: #e55;
--diff-removed-strong: #c44;
--diff-diagonal: #ffffff11;
--diff-scrollbar-rgb: 128, 128, 128;
--gold: #999;
--gold: #999999;
--line: rgba(255, 255, 255, 0.08);
--border-ui: rgba(255, 255, 255, 0.15);
--border-ui-hover: rgba(255, 255, 255, 0.25);
--border-ui-active: rgba(255, 255, 255, 0.4);
--shadow: none;
/* Status colors — monochrome TUI */
--status-success-bg: rgba(255, 255, 255, 0.06);
--status-success-fg: #a0a0a0;
--status-warning-bg: rgba(255, 255, 255, 0.06);
--status-warning-fg: #999999;
--status-error-bg: rgba(255, 255, 255, 0.06);
--status-error-fg: #b0b0b0;
/* Form controls */
--input-border: rgba(255, 255, 255, 0.12);
--input-bg: rgba(14, 28, 37, 0.84);
--input-placeholder: rgba(184, 205, 216, 0.68);
--input-focus-border: rgba(255, 255, 255, 0.4);
--input-focus-ring: rgba(255, 255, 255, 0.12);
--label-fg: rgba(206, 227, 238, 0.76);
/* Interactive states */
--hover-bg: rgba(255, 255, 255, 0.04);
--active-bg: rgba(255, 255, 255, 0.08);
/* Overlay */
--overlay-bg: rgba(10, 10, 10, 0.65);
/* Shadows */
--shadow-dialog: 0 24px 50px rgba(0, 0, 0, 0.35);
--shadow-hover: none;
/* Layout widths */
--page-max: 1536px;
--page-narrow: 900px;
--page-min: 320px;
/* Spacing scale */
--space-1: 4px;
--space-2: 8px;
@@ -104,11 +195,39 @@
--ink: #0a0a0a;
--ink-soft: #555555;
--accent: #0a0a0a;
--accent-fg: #f0f0f0;
--accent-deep: #333333;
--line: rgba(0, 0, 0, 0.12);
--border-ui: rgba(0, 0, 0, 0.2);
--border-ui-hover: rgba(0, 0, 0, 0.35);
--border-ui-active: rgba(0, 0, 0, 0.5);
/* Status colors — light */
--status-success-bg: rgba(0, 0, 0, 0.04);
--status-success-fg: #555555;
--status-warning-bg: rgba(0, 0, 0, 0.04);
--status-warning-fg: #555555;
--status-error-bg: rgba(0, 0, 0, 0.04);
--status-error-fg: #333333;
/* Form controls — light */
--input-border: rgba(0, 0, 0, 0.2);
--input-bg: rgba(255, 255, 255, 0.94);
--input-placeholder: rgba(88, 115, 133, 0.72);
--input-focus-border: rgba(0, 0, 0, 0.5);
--input-focus-ring: rgba(0, 0, 0, 0.08);
--label-fg: rgba(70, 95, 113, 0.9);
/* Interactive states — light */
--hover-bg: rgba(0, 0, 0, 0.04);
--active-bg: rgba(0, 0, 0, 0.08);
/* Overlay — light */
--overlay-bg: rgba(240, 240, 240, 0.65);
/* Shadows — light */
--shadow-dialog: 0 24px 50px rgba(0, 0, 0, 0.12);
--shadow-hover: none;
}
* {
@@ -155,6 +274,7 @@ code {
}
.app-shell {
min-width: var(--page-min);
min-height: 100vh;
display: flex;
flex-direction: column;
@@ -169,7 +289,7 @@ code {
}
.site-footer-inner {
max-width: 1200px;
max-width: var(--page-max);
margin: 0 auto;
}
@@ -225,7 +345,7 @@ code {
}
.navbar-inner {
max-width: 1360px;
max-width: var(--page-max);
margin: 0 auto;
padding: 0 22px;
display: flex;
@@ -354,6 +474,10 @@ code {
color: var(--ink);
}
.navbar-tab:hover svg {
opacity: 0.85;
}
.navbar-tab.active,
.navbar-tab[data-status="active"] {
color: var(--ink);
@@ -361,6 +485,11 @@ code {
border-bottom: 1px solid var(--ink);
}
.navbar-tab.active svg,
.navbar-tab[data-status="active"] svg {
opacity: 1;
}
.navbar-tab-secondary {
font-size: 0.8rem;
opacity: 0.55;
@@ -389,17 +518,16 @@ code {
width: 24px;
height: 24px;
border-radius: var(--r-sm);
background: var(--ink);
display: grid;
place-items: center;
overflow: hidden;
}
.brand-mark img {
width: 70%;
height: 70%;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
border-radius: var(--r-sm);
}
.nav-links {
@@ -540,6 +668,7 @@ code {
color: var(--ink-soft);
}
/* @deprecated — use <Button> from components/ui/button.tsx */
.btn {
border: 1px solid var(--border-ui);
padding: var(--space-2) var(--space-3);
@@ -570,9 +699,9 @@ code {
}
.btn-primary {
background: var(--ink);
color: var(--bg);
border: 1px solid var(--ink);
background: var(--accent);
color: var(--accent-fg);
border: 1px solid var(--accent);
}
.btn-primary:hover {
@@ -587,7 +716,7 @@ code {
}
.hero-inner {
max-width: 1200px;
max-width: var(--page-max);
margin: 0 auto;
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
@@ -666,7 +795,7 @@ code {
.section {
width: 100%;
max-width: 1200px;
max-width: var(--page-max);
margin: 0 auto;
padding: var(--space-5) var(--space-5) var(--space-7);
box-sizing: border-box;
@@ -736,6 +865,7 @@ code {
letter-spacing: -0.01em;
}
/* @deprecated — use <Label> from components/ui/label.tsx */
.form-label {
font-size: 0.74rem;
letter-spacing: 0.14em;
@@ -744,6 +874,7 @@ code {
font-weight: 700;
}
/* @deprecated — use <Input> from components/ui/input.tsx */
.form-input {
width: 100%;
box-sizing: border-box;
@@ -895,7 +1026,7 @@ code {
}
.plugin-publish-lock-note.is-ready {
color: #808080;
color: #818181;
font-weight: 700;
}
@@ -1266,7 +1397,7 @@ code {
.upload-requirement.ok {
background: rgba(43, 198, 164, 0.18);
color: #808080;
color: #818181;
}
.upload-filelist {
@@ -1356,7 +1487,7 @@ code {
.upload-ready {
font-size: 0.9rem;
font-weight: 600;
color: #808080;
color: #818181;
}
.upload-error {
@@ -1708,6 +1839,7 @@ code {
align-items: center;
}
/* @deprecated — use <Card> from components/ui/card.tsx */
.card {
width: 100%;
background: var(--surface);
@@ -2089,6 +2221,7 @@ code {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
/* @deprecated — use <Badge> from components/ui/badge.tsx */
.tag {
display: inline-flex;
align-items: center;
@@ -2200,7 +2333,7 @@ code {
.star-toggle.is-active {
background: var(--accent);
color: white;
color: var(--accent-fg);
border-color: var(--accent-deep);
}
@@ -2215,7 +2348,7 @@ code {
border-radius: 50%;
border: 1px solid rgba(43, 198, 164, 0.45);
background: rgba(43, 198, 164, 0.14);
color: #808080;
color: #818181;
display: grid;
place-items: center;
cursor: pointer;
@@ -2937,7 +3070,7 @@ code {
.diff-pill-added {
background: rgba(43, 198, 164, 0.18);
color: #808080;
color: #818181;
}
.diff-pill-removed {
@@ -5226,7 +5359,7 @@ html.theme-transition::view-transition-new(theme) {
position: relative;
padding: var(--space-6) var(--space-5) var(--space-5);
border-bottom: 1px solid var(--line);
max-width: 1200px;
max-width: var(--page-max);
margin: 0 auto;
isolation: isolate;
overflow: clip;
@@ -5256,7 +5389,7 @@ html.theme-transition::view-transition-new(theme) {
}
.home-hero-inner {
max-width: 1120px;
max-width: var(--page-max);
}
.home-hero-grid {
@@ -5372,7 +5505,7 @@ html.theme-transition::view-transition-new(theme) {
/* Home sections */
.home-section {
max-width: 1200px;
max-width: var(--page-max);
margin: 0 auto;
padding: var(--space-5) var(--space-5) var(--space-6);
}
@@ -5562,7 +5695,7 @@ html.theme-transition::view-transition-new(theme) {
/* ===== Browse Pages with Faceted Sidebar ===== */
.browse-page {
max-width: 1450px;
max-width: var(--page-max);
margin: 0 auto;
padding: var(--space-5) 20px var(--space-7);
}