Compare commits

...
Author SHA1 Message Date
Val AlexanderandCopilot 00e0c77fed Update vite.config.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-02 11:58:10 -05:00
Val Alexanderandgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> 7397d2eccc Update src/routes/skills/index.tsx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-05-02 11:58:00 -05:00
ImLukeF d96cddfdbc fix: recompute other skills category filter 2026-04-06 21:10:42 +10:00
ImLukeF 3f74917b90 test: avoid monaco lazy import in skill detail test 2026-04-06 21:09:10 +10:00
ImLukeF e05883f8d1 fix: address skills filter and webkit review feedback 2026-04-06 21:04:29 +10:00
ImLukeF 15c555a372 fix: remove stale skills toolbar props 2026-04-06 20:52:41 +10:00
ImLukeF a93a6eff0e test: mock auth actions in settings route 2026-04-06 20:41:31 +10:00
ImLukeF c89b13d52b fix: improve auth flow, skill filters, and webkit compatibility 2026-04-06 20:37:10 +10:00
23 changed files with 490 additions and 93 deletions
+4
View File
@@ -24,6 +24,10 @@ vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
vi.mock("../components/SkillDiffCard", () => ({
SkillDiffCard: () => <div data-testid="skill-diff-card" />,
}));
describe("SkillDetailPage", () => {
const skillId = "skills:1" as Id<"skills">;
const ownerId = "users:1" as Id<"users">;
+23
View File
@@ -301,6 +301,29 @@ describe("SkillsIndex", () => {
);
});
it("shows and clears the active capability tag filter", async () => {
searchMock = { tag: "crypto" };
render(<SkillsIndex />);
await act(async () => {});
const capabilityChip = screen.getByRole("button", { name: /crypto/i });
expect(capabilityChip).toBeTruthy();
await act(async () => {
fireEvent.click(capabilityChip);
});
expect(navigateMock).toHaveBeenCalled();
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
replace?: boolean;
search: (prev: Record<string, unknown>) => Record<string, unknown>;
};
expect(lastCall.replace).toBe(true);
expect(lastCall.search({ tag: "crypto" })).toEqual({
tag: undefined,
});
});
it("shows load-more button when more results are available", async () => {
vi.stubGlobal("IntersectionObserver", undefined);
convexHttpMock.query.mockResolvedValue({
+5 -22
View File
@@ -2,13 +2,13 @@ import { useAuthActions } from "@convex-dev/auth/react";
import { Link } from "@tanstack/react-router";
import { Menu, Monitor, Moon, Plus, Search, Sun } from "lucide-react";
import { useMemo, useRef } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import { gravatarUrl } from "../lib/gravatar";
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 { useAuthError } from "../lib/useAuthError";
import { SignInButton } from "./SignInButton";
import { useAuthStatus } from "../lib/useAuthStatus";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Button } from "./ui/button";
@@ -24,7 +24,7 @@ import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
export default function Header() {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const { signIn, signOut } = useAuthActions();
const { signOut } = useAuthActions();
const { mode, setMode } = useThemeMode();
const toggleRef = useRef<HTMLDivElement | null>(null);
const siteMode = getSiteMode();
@@ -37,7 +37,6 @@ export default function Header() {
const initial = (me?.displayName ?? me?.name ?? handle).charAt(0).toUpperCase();
const isStaff = isModerator(me);
const { error: authError, clear: clearAuthError } = useAuthError();
const signInRedirectTo = getCurrentRelativeUrl();
const setTheme = (next: "system" | "light" | "dark") => {
startThemeTransition({
@@ -311,25 +310,14 @@ export default function Header() {
</button>
</div>
) : null}
<Button
<SignInButton
variant="primary"
size="sm"
disabled={isLoading}
onClick={() => {
clearAuthError();
void signIn(
"github",
signInRedirectTo ? { redirectTo: signInRedirectTo } : undefined,
).catch((error) => {
setAuthError(
getUserFacingAuthError(error, "Sign in failed. Please try again."),
);
});
}}
>
<span>Sign in</span>
<span className="hidden text-white/70 sm:inline">with GitHub</span>
</Button>
</SignInButton>
</>
)}
</div>
@@ -337,8 +325,3 @@ export default function Header() {
</header>
);
}
function getCurrentRelativeUrl() {
if (typeof window === "undefined") return "/";
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
+84
View File
@@ -0,0 +1,84 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SignInButton } from "./SignInButton";
const signInMock = vi.fn();
const clearAuthErrorMock = vi.fn();
const setAuthErrorMock = vi.fn();
const getUserFacingAuthErrorMock = vi.fn();
vi.mock("@convex-dev/auth/react", () => ({
useAuthActions: () => ({
signIn: signInMock,
}),
}));
vi.mock("../lib/useAuthError", () => ({
clearAuthError: () => clearAuthErrorMock(),
setAuthError: (message: string) => setAuthErrorMock(message),
}));
vi.mock("../lib/authErrorMessage", () => ({
getUserFacingAuthError: (error: unknown, fallback: string) =>
getUserFacingAuthErrorMock(error, fallback),
}));
describe("SignInButton", () => {
beforeEach(() => {
signInMock.mockReset();
clearAuthErrorMock.mockReset();
setAuthErrorMock.mockReset();
getUserFacingAuthErrorMock.mockReset();
getUserFacingAuthErrorMock.mockImplementation((_, fallback) => fallback);
window.history.replaceState(null, "", "/skills?q=test#top");
});
afterEach(() => {
vi.clearAllMocks();
});
it("starts GitHub sign-in with the current relative URL by default", async () => {
signInMock.mockResolvedValue({ signingIn: true });
render(<SignInButton>Sign in with GitHub</SignInButton>);
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
await waitFor(() => {
expect(signInMock).toHaveBeenCalledWith("github", {
redirectTo: "/skills?q=test#top",
});
});
expect(clearAuthErrorMock).toHaveBeenCalledTimes(1);
expect(setAuthErrorMock).not.toHaveBeenCalled();
});
it("surfaces a generic error when sign-in resolves without redirecting", async () => {
signInMock.mockResolvedValue({ signingIn: false });
render(<SignInButton>Sign in with GitHub</SignInButton>);
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
await waitFor(() => {
expect(setAuthErrorMock).toHaveBeenCalledWith("Sign in failed. Please try again.");
});
});
it("surfaces user-facing auth errors when sign-in rejects", async () => {
const failure = new Error("oauth failed");
signInMock.mockRejectedValue(failure);
getUserFacingAuthErrorMock.mockReturnValue("GitHub auth unavailable");
render(<SignInButton>Sign in with GitHub</SignInButton>);
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
await waitFor(() => {
expect(getUserFacingAuthErrorMock).toHaveBeenCalledWith(
failure,
"Sign in failed. Please try again.",
);
expect(setAuthErrorMock).toHaveBeenCalledWith("GitHub auth unavailable");
});
});
});
+48
View File
@@ -0,0 +1,48 @@
import { useAuthActions } from "@convex-dev/auth/react";
import type { ComponentProps } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import { clearAuthError, setAuthError } from "../lib/useAuthError";
import { Button } from "./ui/button";
type ButtonProps = ComponentProps<typeof Button>;
type SignInButtonProps = Omit<ButtonProps, "onClick" | "type"> & {
redirectTo?: string;
};
export function SignInButton({
redirectTo,
children = "Sign in with GitHub",
...props
}: SignInButtonProps) {
const { signIn } = useAuthActions();
return (
<Button
type="button"
onClick={() => {
clearAuthError();
const next = redirectTo ?? getCurrentRelativeUrl();
void signIn("github", next ? { redirectTo: next } : undefined)
.then((result) => {
if (result?.signingIn === false) {
setAuthError("Sign in failed. Please try again.");
}
})
.catch((error) => {
setAuthError(
getUserFacingAuthError(error, "Sign in failed. Please try again."),
);
});
}}
{...props}
>
{children}
</Button>
);
}
function getCurrentRelativeUrl() {
if (typeof window === "undefined") return "/";
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
+3 -4
View File
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { canManageSkill, isModerator } from "../lib/roles";
import { hasOwnProperty } from "../lib/hasOwnProperty";
import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage";
import { useAuthStatus } from "../lib/useAuthStatus";
import { ClientOnly } from "./ClientOnly";
@@ -37,13 +38,11 @@ type SkillDetailPageProps = {
type SkillFile = Doc<"skillVersions">["files"][number];
function formatReportError(error: unknown) {
if (error && typeof error === "object" && "data" in error) {
if (hasOwnProperty(error, "data")) {
const data = (error as { data?: unknown }).data;
if (typeof data === "string" && data.trim()) return data.trim();
if (
data &&
typeof data === "object" &&
"message" in data &&
hasOwnProperty(data, "message") &&
typeof (data as { message?: unknown }).message === "string"
) {
const message = (data as { message?: string }).message?.trim();
+5 -2
View File
@@ -1,3 +1,4 @@
import { hasOwnProperty } from "../lib/hasOwnProperty";
import type { PublicPublisher, PublicUser } from "../lib/publicUser";
type UserBadgeProps = {
@@ -17,11 +18,13 @@ export function UserBadge({
link = true,
showName = false,
}: UserBadgeProps) {
const userName = user && "name" in user ? user.name?.trim() : undefined;
const userName = hasOwnProperty(user, "name") && typeof user.name === "string"
? user.name.trim()
: undefined;
const displayName = user?.displayName?.trim() || userName || null;
const handle = user?.handle ?? fallbackHandle ?? null;
const href =
user?.handle && "kind" in user
user?.handle && hasOwnProperty(user, "kind")
? user.kind === "org"
? `/orgs/${encodeURIComponent(user.handle)}`
: `/u/${encodeURIComponent(user.handle)}`
+19
View File
@@ -0,0 +1,19 @@
export type SkillCategory = {
slug: string;
label: string;
keywords: string[];
};
export const SKILL_CATEGORIES: SkillCategory[] = [
{ slug: "mcp-tools", label: "MCP Tools", keywords: ["mcp", "tool", "server"] },
{ slug: "prompts", label: "Prompts", keywords: ["prompt", "template", "system"] },
{ slug: "workflows", label: "Workflows", keywords: ["workflow", "pipeline", "chain"] },
{ slug: "dev-tools", label: "Dev Tools", keywords: ["dev", "debug", "lint", "test", "build"] },
{ slug: "data", label: "Data & APIs", keywords: ["api", "data", "fetch", "http", "rest", "graphql"] },
{ slug: "security", label: "Security", keywords: ["security", "scan", "auth", "encrypt"] },
{ slug: "automation", label: "Automation", keywords: ["auto", "cron", "schedule", "bot"] },
{ slug: "other", label: "Other", keywords: [] },
];
export const ALL_CATEGORY_KEYWORDS = SKILL_CATEGORIES.flatMap((c) => c.keywords);
+7 -2
View File
@@ -1,3 +1,5 @@
import { hasOwnProperty } from "./hasOwnProperty";
type ConvexLikeErrorData =
| string
| {
@@ -24,9 +26,12 @@ export function getUserFacingConvexError(error: unknown, fallback: string) {
const candidates: string[] = [];
const maybe = error as ConvexLikeError;
if (maybe && typeof maybe === "object" && "data" in maybe) {
if (hasOwnProperty(maybe, "data")) {
if (typeof maybe.data === "string") candidates.push(maybe.data);
if (maybe.data && typeof maybe.data === "object" && typeof maybe.data.message === "string") {
if (
hasOwnProperty(maybe.data, "message") &&
typeof maybe.data.message === "string"
) {
candidates.push(maybe.data.message);
}
}
+6
View File
@@ -0,0 +1,6 @@
export function hasOwnProperty<K extends PropertyKey>(
value: unknown,
key: K,
): value is Record<K, unknown> {
return typeof value === "object" && value !== null && Object.prototype.hasOwnProperty.call(value, key);
}
+16 -3
View File
@@ -4,6 +4,7 @@ import type {
PackageVerificationSummary,
} from "clawhub-schema";
import { ApiRoutes } from "clawhub-schema/routes";
import { hasOwnProperty } from "./hasOwnProperty";
import { getRequiredRuntimeEnv, getRuntimeEnv } from "./runtimeEnv";
export type PackageListItem = {
@@ -117,6 +118,11 @@ type PluginCatalogResult = {
nextCursor: string | null;
};
type PackageCatalogBrowseResponse = {
items: PackageListItem[];
nextCursor: string | null;
};
type PackageApiErrorOptions = {
status: number;
retryAfterSeconds?: number | null;
@@ -302,10 +308,17 @@ export async function fetchPluginCatalog(params: {
executesCode: params.executesCode,
limit: params.limit,
});
if (hasOwnProperty(response, "results") && Array.isArray(response.results)) {
return {
items: response.results.map((entry) => entry.package),
nextCursor: null,
};
}
const browseResponse = response as PackageCatalogBrowseResponse;
return {
items:
"results" in response ? response.results.map((entry) => entry.package) : response.items,
nextCursor: "results" in response ? null : response.nextCursor,
items: browseResponse.items,
nextCursor: browseResponse.nextCursor,
};
}
+9
View File
@@ -5,17 +5,26 @@ import { Settings } from "./settings";
const useQueryMock = vi.fn();
const useMutationMock = vi.fn();
const useAuthActionsMock = vi.fn();
vi.mock("convex/react", () => ({
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: (...args: unknown[]) => useMutationMock(...args),
}));
vi.mock("@convex-dev/auth/react", () => ({
useAuthActions: () => useAuthActionsMock(),
}));
describe("Settings", () => {
beforeEach(() => {
useQueryMock.mockReset();
useMutationMock.mockReset();
useAuthActionsMock.mockReset();
useMutationMock.mockReturnValue(vi.fn());
useAuthActionsMock.mockReturnValue({
signIn: vi.fn(),
});
});
it("skips token loading until auth has resolved", () => {
+4 -24
View File
@@ -1,14 +1,12 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { createFileRoute } from "@tanstack/react-router";
import { useMutation } from "convex/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { api } from "../../../convex/_generated/api";
import { Container } from "../../components/layout/Container";
import { Button } from "../../components/ui/button";
import { SignInButton } from "../../components/SignInButton";
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
import { getUserFacingAuthError } from "../../lib/authErrorMessage";
import { getClawHubSiteUrl, normalizeClawHubSiteOrigin } from "../../lib/site";
import { setAuthError, useAuthError } from "../../lib/useAuthError";
import { useAuthError } from "../../lib/useAuthError";
import { useAuthStatus } from "../../lib/useAuthStatus";
export const Route = createFileRoute("/cli/auth")({
@@ -17,7 +15,6 @@ export const Route = createFileRoute("/cli/auth")({
function CliAuth() {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const { signIn } = useAuthActions();
const { error: authError, clear: clearAuthError } = useAuthError();
const createToken = useMutation(api.tokens.create);
@@ -35,7 +32,6 @@ function CliAuth() {
const label =
(decodeLabel(search.label_b64) ?? search.label ?? "CLI token").trim() || "CLI token";
const state = typeof search.state === "string" ? search.state.trim() : "";
const signInRedirectTo = getCurrentRelativeUrl();
const safeRedirect = useMemo(() => isAllowedRedirectUri(redirectUri), [redirectUri]);
const registry = useMemo(() => {
@@ -139,23 +135,12 @@ function CliAuth() {
</button>
</p>
) : null}
<Button
<SignInButton
variant="primary"
disabled={isLoading}
onClick={() => {
clearAuthError();
void signIn(
"github",
signInRedirectTo ? { redirectTo: signInRedirectTo } : undefined,
).catch((error) => {
setAuthError(
getUserFacingAuthError(error, "Sign in failed. Please try again."),
);
});
}}
>
Sign in with GitHub
</Button>
</SignInButton>
</CardContent>
</Card>
</Container>
@@ -214,8 +199,3 @@ function decodeLabel(value: string | undefined) {
return null;
}
}
function getCurrentRelativeUrl() {
if (typeof window === "undefined") return "/";
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
+5 -1
View File
@@ -18,6 +18,7 @@ import { api } from "../../convex/_generated/api";
import type { Doc } from "../../convex/_generated/dataModel";
import { EmptyState } from "../components/EmptyState";
import { Container } from "../components/layout/Container";
import { SignInButton } from "../components/SignInButton";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card, CardContent } from "../components/ui/card";
@@ -120,7 +121,10 @@ function Dashboard() {
return (
<Container className="py-10">
<Card>
<CardContent>Sign in to access your dashboard.</CardContent>
<CardContent className="flex flex-col items-start gap-3">
<span>Sign in to access your dashboard.</span>
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
</CardContent>
</Card>
</Container>
);
+6 -1
View File
@@ -5,6 +5,7 @@ import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import { EmptyState } from "../components/EmptyState";
import { Container } from "../components/layout/Container";
import { SignInButton } from "../components/SignInButton";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
@@ -224,7 +225,11 @@ export function ImportGitHub() {
<EmptyState
title={isLoading ? "Loading..." : "Sign in to import and publish skills"}
description="You need to be signed in to import skills from GitHub."
/>
>
{!isLoading ? (
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
) : null}
</EmptyState>
</Container>
</main>
);
+5 -5
View File
@@ -1,4 +1,3 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
import {
PLATFORM_SKILL_LICENSE,
@@ -14,6 +13,7 @@ import { api } from "../../convex/_generated/api";
import { MAX_PUBLISH_FILE_BYTES, MAX_PUBLISH_TOTAL_BYTES } from "../../convex/lib/publishLimits";
import { EmptyState } from "../components/EmptyState";
import { Container } from "../components/layout/Container";
import { SignInButton } from "../components/SignInButton";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardTitle } from "../components/ui/card";
@@ -44,7 +44,6 @@ export const Route = createFileRoute("/publish-skill")({
export function Upload() {
const { isAuthenticated, me } = useAuthStatus();
const { signIn } = useAuthActions();
const { updateSlug } = useSearch({ from: "/publish-skill" });
const siteMode = getSiteMode();
const isSoulMode = siteMode === "souls";
@@ -347,8 +346,9 @@ export function Upload() {
<EmptyState
title={`Sign in to publish a ${contentLabel}`}
description="You need to be signed in to publish skills on ClawHub."
action={{ label: "Sign in", onClick: () => void signIn("github") }}
/>
>
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
</EmptyState>
</Container>
</main>
);
@@ -364,7 +364,7 @@ export function Upload() {
event.preventDefault();
setHasAttempted(true);
if (!validation.ready) {
if (validationRef.current && "scrollIntoView" in validationRef.current) {
if (typeof validationRef.current?.scrollIntoView === "function") {
validationRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
}
return;
+5 -1
View File
@@ -9,6 +9,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { SignInButton } from "../components/SignInButton";
import {
Dialog,
DialogContent,
@@ -107,7 +108,10 @@ export function Settings() {
return (
<Container size="narrow" className="py-10">
<Card>
<CardContent>Sign in to access settings.</CardContent>
<CardContent className="flex flex-col items-start gap-3">
<span>Sign in to access settings.</span>
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
</CardContent>
</Card>
</Container>
);
+77 -15
View File
@@ -1,6 +1,22 @@
import { ArrowDownUp, Check, Grid3X3, List, Search, X } from "lucide-react";
import {
ArrowDownUp,
Check,
Database,
GitBranch,
Grid3X3,
List,
MessageSquare,
Package,
Plug,
Search,
Shield,
Wrench,
X,
Zap,
} from "lucide-react";
import type { RefObject } from "react";
import { SKILL_CAPABILITY_TAGS } from "../../../convex/lib/skillCapabilityTags";
import { useMemo } from "react";
import { SKILL_CATEGORIES, type SkillCategory } from "../../lib/categories";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import {
@@ -40,6 +56,17 @@ const SKILL_CAPABILITY_LABELS: Record<string, string> = {
"posts-externally": "External posting",
};
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
"mcp-tools": <Plug size={13} />,
prompts: <MessageSquare size={13} />,
workflows: <GitBranch size={13} />,
"dev-tools": <Wrench size={13} />,
data: <Database size={13} />,
security: <Shield size={13} />,
automation: <Zap size={13} />,
other: <Package size={13} />,
};
export function SkillsToolbar({
searchInputRef,
query,
@@ -58,6 +85,26 @@ export function SkillsToolbar({
onToggleDir,
onToggleView,
}: SkillsToolbarProps) {
const activeCategory = useMemo(() => {
if (query === "__other__") return "other";
if (!query) return undefined;
return SKILL_CATEGORIES.find((c) =>
c.keywords.some((k) => k === query.trim().toLowerCase()),
)?.slug;
}, [query]);
const handleCategoryChange = (cat: SkillCategory | undefined) => {
if (!cat) {
onQueryChange("");
} else if (cat.slug === "other") {
onQueryChange("__other__");
} else if (cat.keywords[0]) {
onQueryChange(cat.keywords[0]);
} else {
onQueryChange("");
}
};
return (
<div className="flex flex-col gap-3">
{/* Search row */}
@@ -91,18 +138,30 @@ export function SkillsToolbar({
<FilterChip active={nonSuspiciousOnly} onClick={onToggleNonSuspicious}>
Clean only
</FilterChip>
<Select value={capabilityTag ?? "__all__"} onValueChange={onCapabilityTagChange}>
{capabilityTag ? (
<FilterChip
active
onClick={() => onCapabilityTagChange("__all__")}
icon={<X className="h-3 w-3" />}
>
{SKILL_CAPABILITY_LABELS[capabilityTag] ?? capabilityTag}
</FilterChip>
) : null}
<Select value={activeCategory ?? "__all__"} onValueChange={(v) => handleCategoryChange(v === "__all__" ? undefined : SKILL_CATEGORIES.find((c) => c.slug === v))}>
<SelectTrigger
className="w-auto min-w-[156px] min-h-[36px] py-1.5 text-xs font-semibold"
aria-label="Filter by tag"
aria-label="Filter by category"
>
<SelectValue placeholder="All tags" />
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All tags</SelectItem>
{SKILL_CAPABILITY_TAGS.map((tag) => (
<SelectItem key={tag} value={tag}>
{SKILL_CAPABILITY_LABELS[tag] ?? tag}
<SelectItem value="__all__">All categories</SelectItem>
{SKILL_CATEGORIES.map((cat) => (
<SelectItem key={cat.slug} value={cat.slug}>
<span className="inline-flex items-center gap-1.5">
<span className="opacity-60">{CATEGORY_ICONS[cat.slug]}</span>
{cat.label}
</span>
</SelectItem>
))}
</SelectContent>
@@ -131,11 +190,11 @@ export function SkillsToolbar({
</Select>
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={onToggleDir}
aria-label={`Sort direction: ${dir === "asc" ? "ascending" : "descending"}`}
className="min-h-[36px] px-2"
className="min-h-[36px] px-2 rounded-[var(--radius-sm)]"
>
<ArrowDownUp
className={`h-4 w-4 transition-transform ${dir === "asc" ? "rotate-180" : ""}`}
@@ -143,7 +202,7 @@ export function SkillsToolbar({
</Button>
{/* View toggle */}
<div className="inline-flex items-center rounded-[var(--radius-pill)] border border-[color:var(--line)] bg-[color:var(--surface)] p-0.5">
<div className="inline-flex items-center rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] p-0.5">
<button
type="button"
onClick={view === "list" ? onToggleView : undefined}
@@ -178,23 +237,26 @@ function FilterChip({
active,
onClick,
children,
icon,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
icon?: React.ReactNode;
}) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={`inline-flex items-center gap-1.5 rounded-[var(--radius-pill)] border px-3 py-1.5 text-xs font-semibold transition-all duration-150 ${
className={`inline-flex items-center gap-1.5 rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 min-h-[36px] text-xs font-semibold transition-all duration-150 ${
active
? "border-[color:var(--accent)]/30 bg-[color:var(--accent)]/10 text-[color:var(--accent)]"
: "border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink-soft)] hover:border-[color:var(--border-ui-hover)] hover:text-[color:var(--ink)]"
: "text-[color:var(--ink-soft)] hover:border-[color:var(--border-ui-hover)] hover:text-[color:var(--ink)]"
}`}
>
{active && <Check className="h-3 w-3" />}
{active && !icon && <Check className="h-3 w-3" />}
{icon && <span className={active ? "opacity-100" : "opacity-60"}>{icon}</span>}
{children}
</button>
);
+10 -2
View File
@@ -2,6 +2,7 @@ import { useAction } from "convex/react";
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { api } from "../../../convex/_generated/api";
import { convexHttp } from "../../convex/client";
import { ALL_CATEGORY_KEYWORDS } from "../../lib/categories";
import { parseDir, parseSort, toListSort, type SortDir, type SortKey } from "./-params";
import type { SkillListEntry, SkillSearchEntry } from "./-types";
@@ -60,8 +61,9 @@ export function useSkillsBrowseModel({
const capabilityTag = search.tag;
const searchSkills = useAction(api.search.searchSkills);
const isOtherCategory = query === "__other__";
const trimmedQuery = useMemo(() => query.trim(), [query]);
const hasQuery = trimmedQuery.length > 0;
const hasQuery = !isOtherCategory && trimmedQuery.length > 0;
const sort: SortKey =
search.sort === "relevance" && !hasQuery
? "downloads"
@@ -192,6 +194,12 @@ export function useSkillsBrowseModel({
}, [hasQuery, listResults, searchResults]);
const sorted = useMemo(() => {
if (isOtherCategory) {
return baseItems.filter((entry) => {
const text = `${entry.skill.displayName} ${entry.skill.summary ?? ""} ${entry.skill.slug}`.toLowerCase();
return !ALL_CATEGORY_KEYWORDS.some((kw) => text.includes(kw));
});
}
if (!hasQuery) {
return baseItems;
}
@@ -233,7 +241,7 @@ export function useSkillsBrowseModel({
}
});
return results;
}, [baseItems, dir, hasQuery, sort]);
}, [baseItems, dir, hasQuery, isOtherCategory, sort]);
const isLoadingSkills = hasQuery ? isSearching && searchResults.length === 0 : isLoadingList;
const canLoadMore = hasQuery
+11 -8
View File
@@ -78,11 +78,11 @@ export function SkillsIndex() {
<header>
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)]">
Skills
{totalSkillsText && (
<span className="ml-2 text-lg font-normal text-[color:var(--ink-soft)] opacity-70">
({totalSkillsText})
</span>
)}
<span className="ml-2 text-lg font-normal text-[color:var(--ink-soft)] opacity-70">
({model.hasQuery || model.highlightedOnly || model.nonSuspiciousOnly || model.query === "__other__"
? model.sorted.length.toLocaleString("en-US")
: totalSkillsText ?? "…"})
</span>
</h1>
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
{model.isLoadingSkills
@@ -112,11 +112,14 @@ export function SkillsIndex() {
/>
{/* Results count */}
{!model.isLoadingSkills && model.sorted.length > 0 && (
{model.sorted.length > 0 && (
<p className="text-xs font-medium text-[color:var(--ink-soft)]">
Showing {model.sorted.length}
{totalSkillsText ? ` of ${totalSkillsText}` : ""} skills
{model.sorted.length}
{!model.hasQuery && totalSkillsText ? ` of ${totalSkillsText}` : ""} skills
{model.hasQuery ? ` matching "${model.query}"` : ""}
{model.highlightedOnly || model.nonSuspiciousOnly || model.capabilityTag
? ` (filtered)`
: ""}
</p>
)}
+4 -1
View File
@@ -6,6 +6,7 @@ import { api } from "../../convex/_generated/api";
import type { Doc } from "../../convex/_generated/dataModel";
import { EmptyState } from "../components/EmptyState";
import { Container } from "../components/layout/Container";
import { SignInButton } from "../components/SignInButton";
import { Button } from "../components/ui/button";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicSkill } from "../lib/publicUser";
@@ -31,7 +32,9 @@ function Stars() {
icon={Star}
title="Sign in to see your highlights"
description="Star skills for quick access later."
/>
>
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
</EmptyState>
</Container>
</main>
);
+4 -1
View File
@@ -7,6 +7,7 @@ import { api } from "../../../convex/_generated/api";
import type { Doc } from "../../../convex/_generated/dataModel";
import { EmptyState } from "../../components/EmptyState";
import { Container } from "../../components/layout/Container";
import { SignInButton } from "../../components/SignInButton";
import { SkillCardSkeletonGrid } from "../../components/skeletons/SkillCardSkeleton";
import { SkillCard } from "../../components/SkillCard";
import { SkillStatsTripletLine } from "../../components/SkillStats";
@@ -236,7 +237,9 @@ function InstalledSection(props: {
return (
<div className="flex flex-col gap-4">
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Installed</h2>
<EmptyState title="Sign in to view your installed skills" />
<EmptyState title="Sign in to view your installed skills">
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
</EmptyState>
</div>
);
}
+130 -1
View File
@@ -5,7 +5,7 @@ import { devtools } from "@tanstack/devtools-vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { nitro } from "nitro/vite";
import { defineConfig } from "vite";
import { defineConfig, type Plugin } from "vite";
import viteTsConfigPaths from "vite-tsconfig-paths";
const require = createRequire(import.meta.url);
@@ -40,6 +40,134 @@ function handleRollupWarning(
warn(warning);
}
type SourceReplacement = readonly [from: string, to: string];
const reflectHas = (target: string, key: string) => `Reflect.has(${target}, ${JSON.stringify(key)})`;
const arkSafariInOperatorFixes = [
{
suffix: "/node_modules/.vite/deps/arktype.js",
replacements: [
['"expression" in value', reflectHas("value", "expression")],
['"toJSON" in o', reflectHas("o", "toJSON")],
['"morphs" in schema', reflectHas("schema", "morphs")],
['"branches" in schema', reflectHas("schema", "branches")],
['"unit" in schema', reflectHas("schema", "unit")],
['"reference" in schema', reflectHas("schema", "reference")],
['"proto" in schema', reflectHas("schema", "proto")],
['"domain" in schema', reflectHas("schema", "domain")],
['"value" in transformedInner', reflectHas("transformedInner", "value")],
['"default" in this.inner', reflectHas("this.inner", "default")],
['"variadic" in schema', reflectHas("schema", "variadic")],
['"prefix" in schema', reflectHas("schema", "prefix")],
['"defaultables" in schema', reflectHas("schema", "defaultables")],
['"optionals" in schema', reflectHas("schema", "optionals")],
['"postfix" in schema', reflectHas("schema", "postfix")],
['"minVariadicLength" in schema', reflectHas("schema", "minVariadicLength")],
['"description" in ctx', reflectHas("ctx", "description")],
['"data" in input', reflectHas("input", "data")],
['"get" in desc', reflectHas("desc", "get")],
['"set" in desc', reflectHas("desc", "set")],
] satisfies SourceReplacement[],
},
{
suffix: "/node_modules/@ark/util/out/serialize.js",
replacements: [
['"expression" in value', reflectHas("value", "expression")],
['"toJSON" in o', reflectHas("o", "toJSON")],
],
},
{
suffix: "/node_modules/@ark/schema/out/parse.js",
replacements: [
['"morphs" in schema', reflectHas("schema", "morphs")],
['"branches" in schema', reflectHas("schema", "branches")],
['"unit" in schema', reflectHas("schema", "unit")],
['"reference" in schema', reflectHas("schema", "reference")],
['"proto" in schema', reflectHas("schema", "proto")],
['"domain" in schema', reflectHas("schema", "domain")],
] satisfies SourceReplacement[],
},
{
suffix: "/node_modules/@ark/schema/out/node.js",
replacements: [['"value" in transformedInner', reflectHas("transformedInner", "value")]] satisfies SourceReplacement[],
},
{
suffix: "/node_modules/@ark/schema/out/scope.js",
replacements: [['"branches" in schema', reflectHas("schema", "branches")]] satisfies SourceReplacement[],
},
{
suffix: "/node_modules/@ark/schema/out/structure/optional.js",
replacements: [['"default" in this.inner', reflectHas("this.inner", "default")]] satisfies SourceReplacement[],
},
{
suffix: "/node_modules/@ark/schema/out/structure/sequence.js",
replacements: [
['"variadic" in schema', reflectHas("schema", "variadic")],
['"prefix" in schema', reflectHas("schema", "prefix")],
['"defaultables" in schema', reflectHas("schema", "defaultables")],
['"optionals" in schema', reflectHas("schema", "optionals")],
['"postfix" in schema', reflectHas("schema", "postfix")],
['"minVariadicLength" in schema', reflectHas("schema", "minVariadicLength")],
] satisfies SourceReplacement[],
},
{
suffix: "/node_modules/@ark/schema/out/structure/prop.js",
replacements: [['"default" in this.inner', reflectHas("this.inner", "default")]] satisfies SourceReplacement[],
},
{
suffix: "/node_modules/@ark/schema/out/shared/implement.js",
replacements: [['"description" in ctx', reflectHas("ctx", "description")]] satisfies SourceReplacement[],
},
{
suffix: "/node_modules/@ark/schema/out/shared/errors.js",
replacements: [['"data" in input', reflectHas("input", "data")]] satisfies SourceReplacement[],
},
{
suffix: "/node_modules/@ark/util/out/clone.js",
replacements: [
['"get" in desc', reflectHas("desc", "get")],
['"set" in desc', reflectHas("desc", "set")],
] satisfies SourceReplacement[],
},
] as const;
function patchArkSafariInOperator(): Plugin {
return {
name: "patch-ark-safari-in-operator",
enforce: "pre",
transform(code, id) {
const normalizedId = id.split("?")[0].replace(/\\/g, "/");
const fix = arkSafariInOperatorFixes.find((entry) => normalizedId.endsWith(entry.suffix));
if (!fix) return null;
let nextCode = code;
let patchedAny = false;
const missingPatterns: string[] = [];
for (const [from, to] of fix.replacements) {
if (!nextCode.includes(from)) {
missingPatterns.push(from);
continue;
}
nextCode = nextCode.replaceAll(from, to);
patchedAny = true;
}
if (missingPatterns.length > 0) {
this.warn(
`Skipped ${missingPatterns.length} ark safari patch replacement(s) in ${normalizedId}: ${missingPatterns.join(", ")}`,
);
}
if (!patchedAny) return null;
return {
code: nextCode,
map: null,
};
},
};
}
const config = defineConfig({
resolve: {
dedupe: ["convex", "@convex-dev/auth", "react", "react-dom"],
@@ -54,6 +182,7 @@ const config = defineConfig({
include: ["convex/react", "convex/browser"],
},
plugins: [
patchArkSafariInOperator(),
devtools(),
nitro({
serverDir: "server",