mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-15 01:12:11 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fec5db744c | ||
|
|
cb75011244 | ||
|
|
18b23886ab | ||
|
|
beb50f4b49 | ||
|
|
623a414b9b | ||
|
|
91c7e44031 | ||
|
|
850fc9c78e | ||
|
|
f5ec0882d5 | ||
|
|
b2e751c5fc | ||
|
|
4c8032bb97 | ||
|
|
2c435a3d7b | ||
|
|
1f990cf66d |
@@ -95,23 +95,3 @@ When working on Convex code, **always read `convex/_generated/ai/guidelines.md`
|
||||
|
||||
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
|
||||
<!-- convex-ai-end -->
|
||||
|
||||
## Stat Field Migration Rules
|
||||
|
||||
The `skills` table maintains two parallel sets of stat fields as part of an in-progress field migration:
|
||||
|
||||
| Legacy (nested, `@deprecated`) | Top-level (source of truth, indexable) |
|
||||
|---|---|
|
||||
| `stats.downloads` | `statsDownloads` |
|
||||
| `stats.stars` | `statsStars` |
|
||||
| `stats.installsCurrent` | `statsInstallsCurrent` |
|
||||
| `stats.installsAllTime` | `statsInstallsAllTime` |
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Always use `readCanonicalStat(skill, field)` (`convex/lib/skillStats.ts`) to read** any of the four migrated fields. It prefers the top-level field and falls back to the nested field for pre-migration documents. Never access `skill.stats.downloads` / `.stars` / `.installsCurrent` / `.installsAllTime` directly.
|
||||
- **Always use `applySkillStatDeltas()` to write** stat deltas. It writes both the top-level and nested fields in the same patch to keep them in sync.
|
||||
- **Both sets of fields must be written together** in any patch that touches stat values (see the return shape of `applySkillStatDeltas`).
|
||||
- **Nested-only reads are acceptable only for** `stats.comments` and `stats.versions` — no top-level field exists for these yet.
|
||||
- The four legacy nested fields are marked `@deprecated` in `statsValidator` (schema.ts). Any IDE access to `skill.stats.downloads` etc. will show a strikethrough warning — treat this as a signal to use `readCanonicalStat()` instead.
|
||||
- When adding new stat fields, follow the same dual-write pattern and add a cursor-based backfill mutation (see `backfillSkillStatFieldsInternal` for an example).
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
### Changed
|
||||
|
||||
- Search: add CJK tokenization support (Chinese/Japanese/Korean) with Intl.Segmenter plus fallback behavior to improve skill query matching (#1596) (thanks @pq-dong).
|
||||
- Stats: centralize migrated skill stat fallback reads through `readCanonicalStat()` and add schema/agent guardrails to discourage direct legacy nested-field access (#1709) (thanks @momothemage).
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
+11
-27
@@ -10,34 +10,18 @@ type SkillStatDeltas = {
|
||||
installsAllTime?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the canonical value of a migrated stat field from a skill document.
|
||||
*
|
||||
* Top-level fields (`statsDownloads`, etc.) are the source of truth — they are
|
||||
* indexable and kept up-to-date by the event pipeline. The nested `stats.*`
|
||||
* fields are only used as a fallback for pre-migration documents where the
|
||||
* top-level field is still `undefined`.
|
||||
*
|
||||
* All code that reads a migrated stat value should go through this function
|
||||
* rather than accessing `skill.stats.*` directly.
|
||||
*/
|
||||
export function readCanonicalStat(
|
||||
skill: Doc<"skills">,
|
||||
field: "downloads" | "stars" | "installsCurrent" | "installsAllTime",
|
||||
): number {
|
||||
const topLevelKey = `stats${field[0].toUpperCase()}${field.slice(1)}` as
|
||||
| "statsDownloads"
|
||||
| "statsStars"
|
||||
| "statsInstallsCurrent"
|
||||
| "statsInstallsAllTime";
|
||||
return typeof skill[topLevelKey] === "number" ? skill[topLevelKey]! : (skill.stats[field] ?? 0);
|
||||
}
|
||||
|
||||
export function applySkillStatDeltas(skill: Doc<"skills">, deltas: SkillStatDeltas) {
|
||||
const currentDownloads = readCanonicalStat(skill, "downloads");
|
||||
const currentStars = readCanonicalStat(skill, "stars");
|
||||
const currentInstallsCurrent = readCanonicalStat(skill, "installsCurrent");
|
||||
const currentInstallsAllTime = readCanonicalStat(skill, "installsAllTime");
|
||||
const currentDownloads =
|
||||
typeof skill.statsDownloads === "number" ? skill.statsDownloads : skill.stats.downloads;
|
||||
const currentStars = typeof skill.statsStars === "number" ? skill.statsStars : skill.stats.stars;
|
||||
const currentInstallsCurrent =
|
||||
typeof skill.statsInstallsCurrent === "number"
|
||||
? skill.statsInstallsCurrent
|
||||
: (skill.stats.installsCurrent ?? 0);
|
||||
const currentInstallsAllTime =
|
||||
typeof skill.statsInstallsAllTime === "number"
|
||||
? skill.statsInstallsAllTime
|
||||
: (skill.stats.installsAllTime ?? 0);
|
||||
|
||||
const currentComments = skill.stats.comments;
|
||||
const nextDownloads = Math.max(0, currentDownloads + (deltas.downloads ?? 0));
|
||||
|
||||
+7
-7
@@ -2146,9 +2146,9 @@ export const insertReleaseInternal = internalMutation({
|
||||
const nextIsOfficial = nextChannel === "official";
|
||||
const nextOwnerPublisherId = stringifyOptionalId(args.ownerPublisherId ?? null);
|
||||
const nextOwnerUserId = stringifyId(args.ownerUserId);
|
||||
const nextNameLabel = typeof args.name === "string" ? args.name : "<unknown>";
|
||||
const nextRuntimeIdLabel = typeof args.runtimeId === "string" ? args.runtimeId : "<unknown>";
|
||||
const nextVersionLabel = typeof args.version === "string" ? args.version : "<unknown>";
|
||||
const nextName = args.name;
|
||||
const nextRuntimeId = args.runtimeId ?? null;
|
||||
const nextVersion = args.version;
|
||||
if (existing) {
|
||||
const existingIsLegacyPersonalPackage =
|
||||
!existing.ownerPublisherId &&
|
||||
@@ -2171,7 +2171,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
}
|
||||
if (existing && existing.family !== args.family) {
|
||||
throw new ConvexError(
|
||||
`Package "${nextNameLabel}" already exists as a ${existing.family}; family changes are not allowed`,
|
||||
`Package "${nextName}" already exists as a ${existing.family}; family changes are not allowed`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -2182,7 +2182,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
existing.runtimeId !== args.runtimeId
|
||||
) {
|
||||
throw new ConvexError(
|
||||
`Package "${nextNameLabel}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
|
||||
`Package "${nextName}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
|
||||
);
|
||||
}
|
||||
if (args.family === "code-plugin" && args.runtimeId) {
|
||||
@@ -2191,7 +2191,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
.withIndex("by_runtime_id", (q) => q.eq("runtimeId", args.runtimeId))
|
||||
.unique();
|
||||
if (runtimeCollision && runtimeCollision._id !== existing?._id) {
|
||||
throw new ConvexError(`Plugin id "${nextRuntimeIdLabel}" is already claimed by another package`);
|
||||
throw new ConvexError(`Plugin id "${nextRuntimeId}" is already claimed by another package`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2228,7 +2228,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
q.eq("packageId", existing._id).eq("version", args.version),
|
||||
)
|
||||
.unique();
|
||||
if (releaseExists) throw new ConvexError(`Version ${nextVersionLabel} already exists`);
|
||||
if (releaseExists) throw new ConvexError(`Version ${nextVersion} already exists`);
|
||||
}
|
||||
const priorReleases = existing
|
||||
? await ctx.db
|
||||
|
||||
@@ -95,22 +95,10 @@ const badgesValidator = v.optional(
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Nested stat fields on the `skills` document.
|
||||
*
|
||||
* The four migrated fields below are kept for backward compatibility only.
|
||||
* Always use the top-level fields (`statsDownloads`, `statsStars`,
|
||||
* `statsInstallsCurrent`, `statsInstallsAllTime`) as the source of truth,
|
||||
* and use `readCanonicalStat()` / `applySkillStatDeltas()` to read/write them.
|
||||
*/
|
||||
const statsValidator = v.object({
|
||||
/** @deprecated Use top-level `statsDownloads` instead. */
|
||||
downloads: v.number(),
|
||||
/** @deprecated Use top-level `statsInstallsCurrent` instead. */
|
||||
installsCurrent: v.optional(v.number()),
|
||||
/** @deprecated Use top-level `statsInstallsAllTime` instead. */
|
||||
installsAllTime: v.optional(v.number()),
|
||||
/** @deprecated Use top-level `statsStars` instead. */
|
||||
stars: v.number(),
|
||||
versions: v.number(),
|
||||
comments: v.number(),
|
||||
|
||||
+11
-38
@@ -1,39 +1,6 @@
|
||||
import { copyFile, mkdir, stat } from "node:fs/promises";
|
||||
import { copyFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
async function resolveExistingPath(candidates: string[]) {
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await stat(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Try next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Missing required asset. Tried: ${candidates.join(", ")}`);
|
||||
}
|
||||
|
||||
function nodeModuleCandidates(relativePath: string) {
|
||||
return [
|
||||
path.resolve(`node_modules/${relativePath}`),
|
||||
path.resolve(`../../node_modules/${relativePath}`),
|
||||
];
|
||||
}
|
||||
|
||||
const resvgWasmSource = await resolveExistingPath(
|
||||
nodeModuleCandidates("@resvg/resvg-wasm/index_bg.wasm"),
|
||||
);
|
||||
const bricolage800Source = await resolveExistingPath(
|
||||
nodeModuleCandidates("@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2"),
|
||||
);
|
||||
const bricolage500Source = await resolveExistingPath(
|
||||
nodeModuleCandidates("@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2"),
|
||||
);
|
||||
const ibmPlex500Source = await resolveExistingPath(
|
||||
nodeModuleCandidates("@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2"),
|
||||
);
|
||||
|
||||
const copies = [
|
||||
{
|
||||
source: path.resolve("public/clawd-mark.png"),
|
||||
@@ -45,7 +12,7 @@ const copies = [
|
||||
],
|
||||
},
|
||||
{
|
||||
source: resvgWasmSource,
|
||||
source: path.resolve("node_modules/@resvg/resvg-wasm/index_bg.wasm"),
|
||||
targets: [
|
||||
path.resolve(".output/server/node_modules/@resvg/resvg-wasm/index_bg.wasm"),
|
||||
path.resolve(
|
||||
@@ -54,7 +21,9 @@ const copies = [
|
||||
],
|
||||
},
|
||||
{
|
||||
source: bricolage800Source,
|
||||
source: path.resolve(
|
||||
"node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2",
|
||||
),
|
||||
targets: [
|
||||
path.resolve(
|
||||
".output/server/node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2",
|
||||
@@ -65,7 +34,9 @@ const copies = [
|
||||
],
|
||||
},
|
||||
{
|
||||
source: bricolage500Source,
|
||||
source: path.resolve(
|
||||
"node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2",
|
||||
),
|
||||
targets: [
|
||||
path.resolve(
|
||||
".output/server/node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2",
|
||||
@@ -76,7 +47,9 @@ const copies = [
|
||||
],
|
||||
},
|
||||
{
|
||||
source: ibmPlex500Source,
|
||||
source: path.resolve(
|
||||
"node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2",
|
||||
),
|
||||
targets: [
|
||||
path.resolve(
|
||||
".output/server/node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2",
|
||||
|
||||
+94
-130
@@ -9,185 +9,149 @@ const siteModeMock = vi.fn(() => "souls");
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: (props: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
hash?: string;
|
||||
to?: string;
|
||||
}) => (
|
||||
<a
|
||||
href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`}
|
||||
className={props.className}
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/" }),
|
||||
useNavigate: () => navigateMock,
|
||||
Link: (props: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
hash?: string;
|
||||
to?: string;
|
||||
}) => (
|
||||
<a href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`} className={props.className}>
|
||||
{props.children}
|
||||
</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/" }),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("@convex-dev/auth/react", () => ({
|
||||
useAuthActions: () => ({
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
}),
|
||||
useAuthActions: () => ({
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const authStatusMock = vi.fn(() => ({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => authStatusMock(),
|
||||
useAuthStatus: () => authStatusMock(),
|
||||
}));
|
||||
|
||||
const setThemeMock = vi.fn();
|
||||
const setModeMock = vi.fn();
|
||||
|
||||
vi.mock("../lib/theme", () => ({
|
||||
applyTheme: vi.fn(),
|
||||
THEME_OPTIONS: [
|
||||
{ value: "claw", label: "Claw", description: "" },
|
||||
{ value: "hub", label: "Hub", description: "" },
|
||||
],
|
||||
useThemeMode: () => ({
|
||||
theme: "hub",
|
||||
mode: "system",
|
||||
setTheme: setThemeMock,
|
||||
setMode: setModeMock,
|
||||
}),
|
||||
applyTheme: vi.fn(),
|
||||
THEME_OPTIONS: [
|
||||
{ value: "claw", label: "Claw", description: "" },
|
||||
{ value: "hub", label: "Hub", description: "" },
|
||||
],
|
||||
useThemeMode: () => ({
|
||||
theme: "hub",
|
||||
mode: "system",
|
||||
setTheme: setThemeMock,
|
||||
setMode: setModeMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/theme-transition", () => ({
|
||||
startThemeTransition: ({
|
||||
setTheme,
|
||||
nextTheme,
|
||||
}: {
|
||||
setTheme: (value: string) => void;
|
||||
nextTheme: string;
|
||||
}) => setTheme(nextTheme),
|
||||
startThemeTransition: ({
|
||||
setTheme,
|
||||
nextTheme,
|
||||
}: {
|
||||
setTheme: (value: string) => void;
|
||||
nextTheme: string;
|
||||
}) => setTheme(nextTheme),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthError", () => ({
|
||||
setAuthError: vi.fn(),
|
||||
useAuthError: () => ({
|
||||
error: null,
|
||||
clear: vi.fn(),
|
||||
}),
|
||||
setAuthError: vi.fn(),
|
||||
useAuthError: () => ({
|
||||
error: null,
|
||||
clear: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/roles", () => ({
|
||||
isModerator: () => false,
|
||||
isModerator: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/site", () => ({
|
||||
getClawHubSiteUrl: () => "https://clawhub.ai",
|
||||
getSiteMode: () => siteModeMock(),
|
||||
getSiteName: () => "OnlyCrabs",
|
||||
getClawHubSiteUrl: () => "https://clawhub.ai",
|
||||
getSiteMode: () => siteModeMock(),
|
||||
getSiteName: () => "OnlyCrabs",
|
||||
}));
|
||||
|
||||
vi.mock("../lib/gravatar", () => ({
|
||||
gravatarUrl: vi.fn(),
|
||||
gravatarUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuItem: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/toggle-group", () => ({
|
||||
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ToggleGroupItem: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
|
||||
}));
|
||||
|
||||
describe("Header", () => {
|
||||
it("hides Packages navigation in soul mode on mobile and desktop", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
it("hides Packages navigation in soul mode on mobile and desktop", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
|
||||
render(<Header />);
|
||||
render(<Header />);
|
||||
|
||||
expect(screen.queryByText("Packages")).toBeNull();
|
||||
});
|
||||
expect(screen.queryByText("Packages")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders direct desktop theme family controls and plain Skills tab", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
setThemeMock.mockClear();
|
||||
setModeMock.mockClear();
|
||||
it("renders direct desktop theme family controls and plain Skills tab", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
setThemeMock.mockClear();
|
||||
setModeMock.mockClear();
|
||||
|
||||
render(<Header />);
|
||||
render(<Header />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Cycle theme mode/i }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Users")).toHaveLength(1);
|
||||
expect(
|
||||
screen.getByPlaceholderText("Search skills, plugins, users"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /Cycle theme mode/i })).toBeTruthy();
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Users")).toHaveLength(1);
|
||||
expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
|
||||
expect(setModeMock).toHaveBeenCalledWith("light");
|
||||
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
|
||||
expect(setModeMock).toHaveBeenCalledWith("light");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
|
||||
expect(screen.getAllByText("Home")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Users")).toHaveLength(2);
|
||||
});
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Users")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows Home above Skills in the mobile menu", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
it("routes soul-mode header searches to the souls browse page", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
navigateMock.mockReset();
|
||||
|
||||
render(<Header />);
|
||||
render(<Header />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
|
||||
target: { value: "angler" },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
|
||||
|
||||
expect(document.querySelector(".mobile-nav-brand-mark-image")).toBeTruthy();
|
||||
|
||||
const labels = Array.from(
|
||||
document.querySelectorAll(".mobile-nav-section .mobile-nav-link"),
|
||||
)
|
||||
.map((element) => element.textContent?.trim())
|
||||
.filter((label): label is string => Boolean(label));
|
||||
|
||||
expect(labels.slice(0, 2)).toEqual(["Home", "Skills"]);
|
||||
});
|
||||
|
||||
it("routes soul-mode header searches to the souls browse page", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
navigateMock.mockReset();
|
||||
|
||||
render(<Header />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
|
||||
target: { value: "angler" },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
to: "/souls",
|
||||
search: {
|
||||
q: "angler",
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
to: "/souls",
|
||||
search: {
|
||||
q: "angler",
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
MessageSquare,
|
||||
Package,
|
||||
Plug,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { SkillCategory } from "../lib/categories";
|
||||
|
||||
@@ -39,7 +39,7 @@ const CATEGORY_ICONS: Record<string, React.ReactNode> = {
|
||||
"dev-tools": <Wrench size={15} />,
|
||||
data: <Database size={15} />,
|
||||
security: <Shield size={15} />,
|
||||
automation: <RefreshCw size={15} />,
|
||||
automation: <Zap size={15} />,
|
||||
other: <Package size={15} />,
|
||||
};
|
||||
|
||||
|
||||
@@ -129,29 +129,12 @@ export default function Header() {
|
||||
</button>
|
||||
<SheetContent side="left" className="mobile-nav-sheet">
|
||||
<SheetHeader className="pr-10">
|
||||
<SheetTitle>
|
||||
<span className="mobile-nav-brand">
|
||||
<span className="mobile-nav-brand-mark" aria-hidden="true">
|
||||
<img
|
||||
src="/clawd-logo.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="mobile-nav-brand-mark-image"
|
||||
/>
|
||||
</span>
|
||||
<span className="mobile-nav-brand-name">{siteName}</span>
|
||||
</span>
|
||||
</SheetTitle>
|
||||
<SheetTitle>{siteName}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Browse sections, switch theme, and access account actions.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="mobile-nav-section">
|
||||
<SheetClose asChild>
|
||||
<Link to="/" className="mobile-nav-link">
|
||||
Home
|
||||
</Link>
|
||||
</SheetClose>
|
||||
{isSoulMode ? (
|
||||
<SheetClose asChild>
|
||||
<a href={clawHubUrl} className="mobile-nav-link">
|
||||
@@ -219,9 +202,10 @@ export default function Header() {
|
||||
search={{ q: undefined, highlighted: undefined, search: undefined }}
|
||||
className="brand"
|
||||
>
|
||||
<span className="brand-mark">
|
||||
{/* TODO: Re-introduce logo once new asset is ready */}
|
||||
{/* <span className="brand-mark">
|
||||
<img src="/clawd-logo.png" alt="" aria-hidden="true" className="brand-mark-image" />
|
||||
</span>
|
||||
</span> */}
|
||||
<span className="brand-name brand-name-responsive">{siteName}</span>
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { SkillDiffCard } from "./SkillDiffCard";
|
||||
|
||||
const getFileTextMock = vi.fn();
|
||||
let diffEditorMounts = 0;
|
||||
let diffEditorUnmounts = 0;
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useAction: () => getFileTextMock,
|
||||
@@ -21,37 +18,15 @@ vi.mock("@monaco-editor/react", () => ({
|
||||
className?: string;
|
||||
options?: { renderSideBySide?: boolean; useInlineViewWhenSpaceIsLimited?: boolean };
|
||||
}) => (
|
||||
<MockDiffEditor
|
||||
className={className}
|
||||
options={options}
|
||||
/>
|
||||
),
|
||||
useMonaco: () => null,
|
||||
}));
|
||||
|
||||
function MockDiffEditor({
|
||||
className,
|
||||
options,
|
||||
}: {
|
||||
className?: string;
|
||||
options?: { renderSideBySide?: boolean; useInlineViewWhenSpaceIsLimited?: boolean };
|
||||
}) {
|
||||
useEffect(() => {
|
||||
diffEditorMounts += 1;
|
||||
return () => {
|
||||
diffEditorUnmounts += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
data-inline-fallback={String(options?.useInlineViewWhenSpaceIsLimited)}
|
||||
data-side-by-side={String(options?.renderSideBySide)}
|
||||
data-testid="diff-editor"
|
||||
/>
|
||||
);
|
||||
}
|
||||
),
|
||||
useMonaco: () => null,
|
||||
}));
|
||||
|
||||
function installMatchMedia(matches: boolean) {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
@@ -90,8 +65,6 @@ describe("SkillDiffCard", () => {
|
||||
beforeEach(() => {
|
||||
getFileTextMock.mockReset();
|
||||
getFileTextMock.mockResolvedValue({ text: "content" });
|
||||
diffEditorMounts = 0;
|
||||
diffEditorUnmounts = 0;
|
||||
});
|
||||
|
||||
it("defaults to inline mode on narrow screens", async () => {
|
||||
@@ -135,34 +108,4 @@ describe("SkillDiffCard", () => {
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Side-by-side" }).className).toContain("is-active");
|
||||
});
|
||||
|
||||
it("keeps the diff editor mounted when toggling view mode", async () => {
|
||||
installMatchMedia(false);
|
||||
|
||||
render(
|
||||
<SkillDiffCard
|
||||
skill={skill}
|
||||
versions={[
|
||||
makeVersion("skillVersions:1", "1.0.1"),
|
||||
makeVersion("skillVersions:2", "1.0.2"),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("diff-editor")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(diffEditorMounts).toBe(1);
|
||||
expect(diffEditorUnmounts).toBe(0);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Inline" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("diff-editor").getAttribute("data-side-by-side")).toBe("false");
|
||||
});
|
||||
|
||||
expect(diffEditorMounts).toBe(1);
|
||||
expect(diffEditorUnmounts).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -402,6 +402,7 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
|
||||
) : (
|
||||
<ClientOnly fallback={<div className="diff-empty">Preparing diff…</div>}>
|
||||
<DiffEditor
|
||||
key={`diff-${viewMode}`}
|
||||
className={`diff-monaco diff-monaco-${viewMode}`}
|
||||
original={leftText}
|
||||
modified={rightText}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { usePreferences, getStoredPreferencesSnapshot } from "./preferences";
|
||||
|
||||
const PREFERENCES_KEY = "clawhub-preferences";
|
||||
|
||||
function PreferencesProbe() {
|
||||
const { preferences, updatePreference, isAdvancedMode } = usePreferences();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="density">{preferences.layoutDensity}</div>
|
||||
<div data-testid="advanced">{String(isAdvancedMode)}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updatePreference("advancedMode", !preferences.advancedMode)}
|
||||
>
|
||||
Toggle advanced
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("preferences store", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("returns a stable snapshot when storage has not changed", () => {
|
||||
const first = getStoredPreferencesSnapshot();
|
||||
const second = getStoredPreferencesSnapshot();
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(first.layoutDensity).toBe("comfortable");
|
||||
});
|
||||
|
||||
it("falls back to defaults when localStorage reads throw", () => {
|
||||
const getItemSpy = vi
|
||||
.spyOn(Storage.prototype, "getItem")
|
||||
.mockImplementation(() => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
});
|
||||
|
||||
render(<PreferencesProbe />);
|
||||
|
||||
expect(screen.getByTestId("density").textContent).toBe("comfortable");
|
||||
expect(screen.getByTestId("advanced").textContent).toBe("false");
|
||||
|
||||
getItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("resets to defaults when the preference key is cleared in another tab", () => {
|
||||
window.localStorage.setItem(
|
||||
PREFERENCES_KEY,
|
||||
JSON.stringify({
|
||||
advancedMode: true,
|
||||
layoutDensity: "compact",
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PreferencesProbe />);
|
||||
expect(screen.getByTestId("advanced").textContent).toBe("true");
|
||||
expect(screen.getByTestId("density").textContent).toBe("compact");
|
||||
|
||||
window.localStorage.removeItem(PREFERENCES_KEY);
|
||||
act(() => {
|
||||
const event = Object.assign(new Event("storage"), {
|
||||
key: PREFERENCES_KEY,
|
||||
newValue: null,
|
||||
oldValue: JSON.stringify({
|
||||
advancedMode: true,
|
||||
layoutDensity: "compact",
|
||||
}),
|
||||
storageArea: window.localStorage,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("advanced").textContent).toBe("false");
|
||||
expect(screen.getByTestId("density").textContent).toBe("comfortable");
|
||||
});
|
||||
|
||||
it("re-renders cleanly after a preference update", () => {
|
||||
window.localStorage.setItem(
|
||||
PREFERENCES_KEY,
|
||||
JSON.stringify({
|
||||
advancedMode: false,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PreferencesProbe />);
|
||||
|
||||
expect(screen.getByTestId("advanced").textContent).toBe("false");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /toggle advanced/i }));
|
||||
|
||||
expect(screen.getByTestId("advanced").textContent).toBe("true");
|
||||
expect(JSON.parse(window.localStorage.getItem(PREFERENCES_KEY) ?? "{}")).toMatchObject({
|
||||
advancedMode: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not cache a preference change when storage writes fail", () => {
|
||||
const setItemSpy = vi
|
||||
.spyOn(Storage.prototype, "setItem")
|
||||
.mockImplementation(() => {
|
||||
throw new DOMException("quota", "QuotaExceededError");
|
||||
});
|
||||
|
||||
render(<PreferencesProbe />);
|
||||
expect(screen.getByTestId("advanced").textContent).toBe("false");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /toggle advanced/i }));
|
||||
|
||||
expect(screen.getByTestId("advanced").textContent).toBe("false");
|
||||
expect(window.localStorage.getItem(PREFERENCES_KEY)).toBeNull();
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
+19
-79
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useSyncExternalStore } from "react";
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
|
||||
const PREFERENCES_KEY = "clawhub-preferences";
|
||||
|
||||
@@ -66,95 +66,36 @@ const defaultPreferences: UserPreferences = {
|
||||
|
||||
// Simple event emitter for cross-tab sync
|
||||
const listeners = new Set<() => void>();
|
||||
let cachedPreferencesRaw: string | null = null;
|
||||
let cachedPreferencesSnapshot: UserPreferences = defaultPreferences;
|
||||
let hasCachedPreferences = false;
|
||||
let removeStorageListener: (() => void) | null = null;
|
||||
|
||||
function normalizePreferences(parsed: Partial<UserPreferences> | null): UserPreferences {
|
||||
if (!parsed) return defaultPreferences;
|
||||
return { ...defaultPreferences, ...parsed };
|
||||
}
|
||||
|
||||
function parsePreferences(raw: string | null): UserPreferences {
|
||||
if (!raw) return defaultPreferences;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<UserPreferences>;
|
||||
return normalizePreferences(parsed);
|
||||
} catch {
|
||||
return defaultPreferences;
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredPreferences(): UserPreferences {
|
||||
if (typeof window === "undefined") return defaultPreferences;
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(PREFERENCES_KEY);
|
||||
if (hasCachedPreferences && stored === cachedPreferencesRaw) {
|
||||
return cachedPreferencesSnapshot;
|
||||
}
|
||||
|
||||
cachedPreferencesRaw = stored;
|
||||
cachedPreferencesSnapshot = parsePreferences(stored);
|
||||
hasCachedPreferences = true;
|
||||
return cachedPreferencesSnapshot;
|
||||
} catch {
|
||||
cachedPreferencesRaw = null;
|
||||
cachedPreferencesSnapshot = defaultPreferences;
|
||||
hasCachedPreferences = true;
|
||||
return cachedPreferencesSnapshot;
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
|
||||
if (typeof window !== "undefined" && !removeStorageListener) {
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.storageArea !== window.localStorage || event.key !== PREFERENCES_KEY) {
|
||||
return;
|
||||
}
|
||||
|
||||
cachedPreferencesRaw = event.newValue;
|
||||
cachedPreferencesSnapshot = parsePreferences(event.newValue);
|
||||
hasCachedPreferences = true;
|
||||
notifyListeners();
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorage);
|
||||
removeStorageListener = () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
removeStorageListener = null;
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
|
||||
if (listeners.size === 0 && removeStorageListener) {
|
||||
removeStorageListener();
|
||||
}
|
||||
};
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function notifyListeners() {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function getStoredPreferences(): UserPreferences {
|
||||
if (typeof window === "undefined") return defaultPreferences;
|
||||
try {
|
||||
const stored = window.localStorage.getItem(PREFERENCES_KEY);
|
||||
if (!stored) return defaultPreferences;
|
||||
const parsed = JSON.parse(stored) as Partial<UserPreferences>;
|
||||
return { ...defaultPreferences, ...parsed };
|
||||
} catch {
|
||||
return defaultPreferences;
|
||||
}
|
||||
}
|
||||
|
||||
function savePreferences(prefs: UserPreferences) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const serialized = JSON.stringify(prefs);
|
||||
window.localStorage.setItem(PREFERENCES_KEY, serialized);
|
||||
cachedPreferencesRaw = serialized;
|
||||
cachedPreferencesSnapshot = prefs;
|
||||
hasCachedPreferences = true;
|
||||
window.localStorage.setItem(PREFERENCES_KEY, JSON.stringify(prefs));
|
||||
notifyListeners();
|
||||
} catch {
|
||||
// Storage might be full or disabled
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Server snapshot for SSR
|
||||
@@ -165,7 +106,7 @@ function getServerSnapshot(): UserPreferences {
|
||||
export function usePreferences() {
|
||||
const preferences = useSyncExternalStore(
|
||||
subscribe,
|
||||
readStoredPreferences,
|
||||
getStoredPreferences,
|
||||
getServerSnapshot
|
||||
);
|
||||
|
||||
@@ -173,13 +114,13 @@ export function usePreferences() {
|
||||
key: K,
|
||||
value: UserPreferences[K]
|
||||
) => {
|
||||
const current = readStoredPreferences();
|
||||
const current = getStoredPreferences();
|
||||
const updated = { ...current, [key]: value };
|
||||
savePreferences(updated);
|
||||
}, []);
|
||||
|
||||
const updatePreferences = useCallback((updates: Partial<UserPreferences>) => {
|
||||
const current = readStoredPreferences();
|
||||
const current = getStoredPreferences();
|
||||
const updated = { ...current, ...updates };
|
||||
savePreferences(updated);
|
||||
}, []);
|
||||
@@ -222,4 +163,3 @@ export function usePreferences() {
|
||||
}
|
||||
|
||||
export { defaultPreferences };
|
||||
export { readStoredPreferences as getStoredPreferencesSnapshot };
|
||||
|
||||
+20
-8
@@ -7,10 +7,12 @@ import {
|
||||
ChevronRight,
|
||||
Code2,
|
||||
Download,
|
||||
Package,
|
||||
Layers,
|
||||
Search,
|
||||
Shield,
|
||||
Star,
|
||||
Users,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { SoulCard } from "../components/SoulCard";
|
||||
@@ -450,28 +452,28 @@ function SkillsHome() {
|
||||
className="home-v2-suggestion"
|
||||
onClick={() => handleSuggestion("self-improving agent")}
|
||||
>
|
||||
self-improving agent
|
||||
<Zap size={13} /> self-improving agent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="home-v2-suggestion"
|
||||
onClick={() => handleSuggestion("GitHub integration")}
|
||||
>
|
||||
GitHub integration
|
||||
<Code2 size={13} /> GitHub integration
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="home-v2-suggestion"
|
||||
onClick={() => handleSuggestion("security soul")}
|
||||
>
|
||||
security soul
|
||||
<Shield size={13} /> security soul
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="home-v2-suggestion"
|
||||
onClick={() => handleSuggestion("dashboard builder")}
|
||||
>
|
||||
dashboard builder
|
||||
<Layers size={13} /> dashboard builder
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -500,6 +502,9 @@ function SkillsHome() {
|
||||
className="home-v2-c-card"
|
||||
>
|
||||
<div className="home-v2-c-head">
|
||||
<div className="home-v2-c-icon">
|
||||
<Zap size={18} />
|
||||
</div>
|
||||
<div className="home-v2-c-meta">
|
||||
<div className="home-v2-c-name">
|
||||
{entry.skill.displayName || entry.skill.slug}
|
||||
@@ -509,7 +514,9 @@ function SkillsHome() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="home-v2-c-tag">Skill</span>
|
||||
<span className="home-v2-c-tag">
|
||||
<Zap size={11} /> Skill
|
||||
</span>
|
||||
<div className="home-v2-c-desc">
|
||||
{entry.skill.summary || "A fresh skill bundle."}
|
||||
</div>
|
||||
@@ -538,6 +545,9 @@ function SkillsHome() {
|
||||
className="home-v2-c-card"
|
||||
>
|
||||
<div className="home-v2-c-head">
|
||||
<div className="home-v2-c-icon">
|
||||
<Zap size={18} />
|
||||
</div>
|
||||
<div className="home-v2-c-meta">
|
||||
<div className="home-v2-c-name">
|
||||
{entry.skill.displayName || entry.skill.slug}
|
||||
@@ -547,7 +557,9 @@ function SkillsHome() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="home-v2-c-tag">Skill</span>
|
||||
<span className="home-v2-c-tag">
|
||||
<Zap size={11} /> Skill
|
||||
</span>
|
||||
<div className="home-v2-c-desc">
|
||||
{entry.skill.summary || "A fresh skill bundle."}
|
||||
</div>
|
||||
@@ -590,7 +602,7 @@ function SkillsHome() {
|
||||
className="home-v2-cat-item"
|
||||
>
|
||||
<div className="home-v2-cat-icon">
|
||||
<Package size={20} />
|
||||
<Zap size={20} />
|
||||
</div>
|
||||
<div className="home-v2-cat-text">
|
||||
<div className="home-v2-cat-name">Skills</div>
|
||||
|
||||
+10
-3
@@ -9,7 +9,9 @@ import {
|
||||
Moon,
|
||||
RotateCcw,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -493,7 +495,8 @@ export function Settings() {
|
||||
|
||||
{/* Code & Content Section - Advanced */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)]">
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
|
||||
<Sparkles size={14} className="text-[color:var(--accent)]" />
|
||||
Code & Content
|
||||
</Label>
|
||||
|
||||
@@ -530,7 +533,10 @@ export function Settings() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full">
|
||||
Full
|
||||
<span className="flex items-center gap-2">
|
||||
<Zap size={14} />
|
||||
Full
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="reduced">Reduced</SelectItem>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
@@ -598,7 +604,8 @@ export function Settings() {
|
||||
|
||||
{/* Experimental Features - Advanced */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)]">
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
|
||||
<Sparkles size={14} className="text-[color:var(--gold)]" />
|
||||
Experimental
|
||||
</Label>
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
MessageSquare,
|
||||
Package,
|
||||
Plug,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Shield,
|
||||
Wrench,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { RefObject } from "react";
|
||||
import { useMemo } from "react";
|
||||
@@ -64,7 +64,7 @@ const CATEGORY_ICONS: Record<string, React.ReactNode> = {
|
||||
"dev-tools": <Wrench size={13} />,
|
||||
data: <Database size={13} />,
|
||||
security: <Shield size={13} />,
|
||||
automation: <RefreshCw size={13} />,
|
||||
automation: <Zap size={13} />,
|
||||
other: <Package size={13} />,
|
||||
};
|
||||
|
||||
|
||||
+233
-124
@@ -93,13 +93,13 @@
|
||||
color-scheme: dark;
|
||||
|
||||
/* OpenClaw — black, white, red brand palette */
|
||||
--bg: #060608;
|
||||
--bg-soft: #0d0d0f;
|
||||
--bg-glow-1: #101012;
|
||||
--bg-glow-2: #141416;
|
||||
--surface: #0e0e10;
|
||||
--surface-muted: #131315;
|
||||
--nav-bg: rgba(6, 6, 8, 0.96);
|
||||
--bg: #0a0a0a;
|
||||
--bg-soft: #111111;
|
||||
--bg-glow-1: #141414;
|
||||
--bg-glow-2: #181818;
|
||||
--surface: #121212;
|
||||
--surface-muted: #171717;
|
||||
--nav-bg: rgba(10, 10, 10, 0.96);
|
||||
--ink: #fafafa;
|
||||
--ink-soft: #a1a1a1;
|
||||
--accent: #dc2626;
|
||||
@@ -130,7 +130,7 @@
|
||||
|
||||
/* Form controls — crisp, modern */
|
||||
--input-border: rgba(255, 255, 255, 0.1);
|
||||
--input-bg: rgba(14, 14, 16, 0.9);
|
||||
--input-bg: rgba(18, 18, 18, 0.9);
|
||||
--input-placeholder: rgba(161, 161, 161, 0.7);
|
||||
--input-focus-border: rgba(220, 38, 38, 0.5);
|
||||
--input-focus-ring: rgba(220, 38, 38, 0.2);
|
||||
@@ -141,7 +141,7 @@
|
||||
--active-bg: rgba(220, 38, 38, 0.1);
|
||||
|
||||
/* Overlay */
|
||||
--overlay-bg: rgba(5, 5, 7, 0.75);
|
||||
--overlay-bg: rgba(9, 9, 11, 0.75);
|
||||
|
||||
/* Shadows — refined depth */
|
||||
--shadow-dialog: 0 24px 48px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
@@ -173,12 +173,12 @@
|
||||
--fs-2xl: 1.5rem;
|
||||
--fs-3xl: 2rem;
|
||||
|
||||
/* Unified radius — one consistent size everywhere */
|
||||
--r-lg: 8px;
|
||||
/* Modern rounded corners — polished but efficient */
|
||||
--r-lg: 12px;
|
||||
--r-md: 8px;
|
||||
--r-sm: 8px;
|
||||
--r-xs: 8px;
|
||||
--r-pill: 8px;
|
||||
--r-sm: 6px;
|
||||
--r-xs: 4px;
|
||||
--r-pill: 9999px;
|
||||
|
||||
/* Modern typography — clean and readable */
|
||||
--font-display: "Bricolage Grotesque", "Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
@@ -189,11 +189,11 @@
|
||||
/* Light theme — OpenClaw black, white, red */
|
||||
[data-theme-family="claw"][data-theme-resolved="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #faf6f1;
|
||||
--bg-soft: #f5f1ec;
|
||||
--surface: #fffcf8;
|
||||
--surface-muted: #f8f5f0;
|
||||
--nav-bg: rgba(250, 246, 241, 0.96);
|
||||
--bg: #fafafa;
|
||||
--bg-soft: #f5f5f5;
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #f8f8f8;
|
||||
--nav-bg: rgba(250, 250, 250, 0.96);
|
||||
--ink: #0a0a0a;
|
||||
--ink-soft: #525252;
|
||||
--accent: #dc2626;
|
||||
@@ -215,7 +215,7 @@
|
||||
|
||||
/* Form controls — light */
|
||||
--input-border: rgba(0, 0, 0, 0.12);
|
||||
--input-bg: #fffcf8;
|
||||
--input-bg: #ffffff;
|
||||
--input-placeholder: rgba(82, 82, 82, 0.6);
|
||||
--input-focus-border: rgba(220, 38, 38, 0.5);
|
||||
--input-focus-ring: rgba(220, 38, 38, 0.15);
|
||||
@@ -226,7 +226,7 @@
|
||||
--active-bg: rgba(220, 38, 38, 0.08);
|
||||
|
||||
/* Overlay — light */
|
||||
--overlay-bg: rgba(250, 246, 241, 0.75);
|
||||
--overlay-bg: rgba(250, 250, 250, 0.75);
|
||||
|
||||
/* Shadows — light */
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
@@ -241,11 +241,11 @@
|
||||
[data-theme-family="hub"] {
|
||||
--page-max: 1536px;
|
||||
--page-narrow: 900px;
|
||||
--r-lg: 8px;
|
||||
--r-md: 8px;
|
||||
--r-sm: 8px;
|
||||
--r-xs: 8px;
|
||||
--r-pill: 8px;
|
||||
--r-lg: 2px;
|
||||
--r-md: 2px;
|
||||
--r-sm: 1px;
|
||||
--r-xs: 1px;
|
||||
--r-pill: 2px;
|
||||
--fs-xs: 0.72rem;
|
||||
--fs-sm: 0.82rem;
|
||||
--fs-base: 0.88rem;
|
||||
@@ -263,13 +263,13 @@
|
||||
|
||||
[data-theme-family="hub"][data-theme-resolved="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #060608;
|
||||
--bg-soft: #0d0d0f;
|
||||
--bg-glow-1: #0d0d0f;
|
||||
--bg-glow-2: #0d0d0f;
|
||||
--surface: #101012;
|
||||
--surface-muted: #161618;
|
||||
--nav-bg: rgba(6, 6, 8, 0.95);
|
||||
--bg: #0a0a0a;
|
||||
--bg-soft: #111111;
|
||||
--bg-glow-1: #111111;
|
||||
--bg-glow-2: #111111;
|
||||
--surface: #141414;
|
||||
--surface-muted: #1a1a1a;
|
||||
--nav-bg: rgba(10, 10, 10, 0.95);
|
||||
--ink: #e0e0e0;
|
||||
--ink-soft: #818181;
|
||||
--accent: #ef4444;
|
||||
@@ -294,25 +294,25 @@
|
||||
--status-error-bg: rgba(239, 68, 68, 0.12);
|
||||
--status-error-fg: #f87171;
|
||||
--input-border: rgba(255, 255, 255, 0.12);
|
||||
--input-bg: rgba(16, 16, 18, 0.96);
|
||||
--input-bg: rgba(20, 20, 20, 0.96);
|
||||
--input-placeholder: rgba(184, 184, 184, 0.68);
|
||||
--input-focus-border: rgba(239, 68, 68, 0.48);
|
||||
--input-focus-ring: rgba(239, 68, 68, 0.16);
|
||||
--label-fg: rgba(224, 224, 224, 0.78);
|
||||
--hover-bg: rgba(255, 255, 255, 0.03);
|
||||
--active-bg: rgba(239, 68, 68, 0.1);
|
||||
--overlay-bg: rgba(6, 6, 8, 0.66);
|
||||
--overlay-bg: rgba(10, 10, 10, 0.66);
|
||||
--shadow-dialog: 0 24px 50px rgba(0, 0, 0, 0.35);
|
||||
--shadow-card: none;
|
||||
}
|
||||
|
||||
[data-theme-family="hub"][data-theme-resolved="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #ece8e3;
|
||||
--bg-soft: #e4e0db;
|
||||
--surface: #fdfaf6;
|
||||
--surface-muted: #f5f1ec;
|
||||
--nav-bg: rgba(236, 232, 227, 0.95);
|
||||
--bg: #f0f0f0;
|
||||
--bg-soft: #e8e8e8;
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #f5f5f5;
|
||||
--nav-bg: rgba(240, 240, 240, 0.95);
|
||||
--ink: #0a0a0a;
|
||||
--ink-soft: #555555;
|
||||
--accent: #dc2626;
|
||||
@@ -337,14 +337,14 @@
|
||||
--status-error-bg: rgba(220, 38, 38, 0.1);
|
||||
--status-error-fg: #b91c1c;
|
||||
--input-border: rgba(0, 0, 0, 0.18);
|
||||
--input-bg: rgba(253, 250, 246, 0.94);
|
||||
--input-bg: rgba(255, 255, 255, 0.94);
|
||||
--input-placeholder: rgba(85, 85, 85, 0.64);
|
||||
--input-focus-border: rgba(220, 38, 38, 0.42);
|
||||
--input-focus-ring: rgba(220, 38, 38, 0.12);
|
||||
--label-fg: rgba(10, 10, 10, 0.72);
|
||||
--hover-bg: rgba(0, 0, 0, 0.03);
|
||||
--active-bg: rgba(220, 38, 38, 0.08);
|
||||
--overlay-bg: rgba(236, 232, 227, 0.7);
|
||||
--overlay-bg: rgba(240, 240, 240, 0.7);
|
||||
--shadow-dialog: 0 24px 50px rgba(0, 0, 0, 0.12);
|
||||
--shadow-card: none;
|
||||
}
|
||||
@@ -640,15 +640,15 @@ code {
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding: var(--space-3) var(--space-5);
|
||||
padding: var(--space-6) var(--space-5);
|
||||
margin-top: auto;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.site-footer-inner {
|
||||
width: 100%;
|
||||
padding: 0 var(--space-5);
|
||||
max-width: var(--page-max);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.site-footer-divider {
|
||||
@@ -700,7 +700,8 @@ code {
|
||||
}
|
||||
|
||||
.navbar-inner {
|
||||
width: 100%;
|
||||
max-width: var(--page-max);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -912,11 +913,6 @@ code {
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--ink) 8%, transparent),
|
||||
0 1px 2px rgba(0, 0, 0, 0.10),
|
||||
0 2px 6px rgba(0, 0, 0, 0.05);
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.brand-mark img {
|
||||
@@ -924,7 +920,6 @@ code {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: var(--r-sm);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
@@ -981,39 +976,6 @@ code {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.mobile-nav-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mobile-nav-brand-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--r-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--ink) 8%, transparent),
|
||||
0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-nav-brand-mark-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: var(--r-sm);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.mobile-nav-brand-name {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -4305,7 +4267,7 @@ code {
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding: 0 18px 18px;
|
||||
padding: 0 18px 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7150,16 +7112,14 @@ html.theme-transition::view-transition-new(theme) {
|
||||
.footer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) 0 var(--space-2);
|
||||
text-align: center;
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-6) 0 var(--space-5);
|
||||
}
|
||||
|
||||
.footer-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.footer-col-title {
|
||||
@@ -7168,7 +7128,7 @@ html.theme-transition::view-transition-new(theme) {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ink-soft);
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.footer-col a,
|
||||
@@ -7183,7 +7143,7 @@ html.theme-transition::view-transition-new(theme) {
|
||||
|
||||
.footer-bottom {
|
||||
border-top: 1px solid var(--line);
|
||||
padding: var(--space-2) 0;
|
||||
padding: var(--space-4) 0;
|
||||
text-align: center;
|
||||
font-size: var(--fs-sm);
|
||||
letter-spacing: 0.015em;
|
||||
@@ -7205,14 +7165,14 @@ html.theme-transition::view-transition-new(theme) {
|
||||
@media (max-width: 760px) {
|
||||
.footer-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-3);
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.footer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-3);
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8148,24 +8108,6 @@ html.theme-transition::view-transition-new(theme) {
|
||||
color: var(--hv2-accent);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.home-v2-suggestions {
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.home-v2-suggestions-label {
|
||||
font-size: 12px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.home-v2-suggestion {
|
||||
font-size: 12px;
|
||||
padding: 5px 10px;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══ CAROUSEL ═══ */
|
||||
.home-v2-carousel-section {
|
||||
padding: 48px 0 0;
|
||||
@@ -8763,7 +8705,57 @@ html.theme-transition::view-transition-new(theme) {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* ═══ HOME V2 — Layout ═══ */
|
||||
/* ═══ MINIMAL FOOTER (home-v2 pages) ═══ */
|
||||
|
||||
/* Fix: use :has() since main is wrapped in ErrorBoundary */
|
||||
.app-shell:has(.home-v2-main) > .site-footer .footer-grid {
|
||||
display: none;
|
||||
}
|
||||
.app-shell:has(.home-v2-main) > .site-footer .site-footer-divider {
|
||||
display: none;
|
||||
}
|
||||
.app-shell:has(.home-v2-main) > .site-footer {
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.app-shell:has(.home-v2-main) > .site-footer .site-footer-inner {
|
||||
max-width: none;
|
||||
padding: 20px 48px;
|
||||
}
|
||||
.app-shell:has(.home-v2-main) > .site-footer .footer-bottom {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.app-shell:has(.home-v2-main) > .site-footer .footer-bottom a {
|
||||
color: #777;
|
||||
}
|
||||
.app-shell:has(.home-v2-main) > .site-footer .footer-bottom a:hover {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
/* ═══ HOME V2 — Page-max constraint + full-bleed background ═══ */
|
||||
.app-shell:has(.home-v2-main) {
|
||||
background: #060608;
|
||||
}
|
||||
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) {
|
||||
background: #faf6f1;
|
||||
}
|
||||
|
||||
/* ═══ HOME V2 — Full-width nav override ═══ */
|
||||
.app-shell:has(.home-v2-main) > header,
|
||||
.app-shell:has(.home-v2-main) > nav,
|
||||
.app-shell:has(.home-v2-main) .site-header,
|
||||
.app-shell:has(.home-v2-main) .site-header-inner {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ═══ HOME V2 — Wider search + even dot spacing ═══ */
|
||||
.home-v2-search-container {
|
||||
@@ -8787,6 +8779,10 @@ html.theme-transition::view-transition-new(theme) {
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
/* Override navbar max-width on home v2 */
|
||||
.app-shell:has(.home-v2-main) .navbar-inner {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
HOME V2 — Cream / Peach / Tan Refinement (light + dark)
|
||||
@@ -9029,6 +9025,55 @@ html.theme-transition::view-transition-new(theme) {
|
||||
border-top-color: rgba(170, 125, 80, 0.12);
|
||||
}
|
||||
|
||||
/* Light — footer */
|
||||
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) > .site-footer {
|
||||
border-top-color: rgba(170, 125, 80, 0.15);
|
||||
}
|
||||
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) > .site-footer .footer-bottom {
|
||||
color: #9c8b7a;
|
||||
}
|
||||
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) > .site-footer .footer-bottom a {
|
||||
color: #6b5c4e;
|
||||
}
|
||||
|
||||
/* Light — nav/header blends with cream bg */
|
||||
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar {
|
||||
background: rgba(250, 246, 241, 0.95);
|
||||
}
|
||||
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .brand,
|
||||
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .brand-name {
|
||||
color: #1a1410;
|
||||
}
|
||||
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-search-home {
|
||||
background: #fff8f2;
|
||||
border-color: rgba(170, 125, 80, 0.2);
|
||||
color: #6b5c4e;
|
||||
}
|
||||
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-search-home:hover {
|
||||
border-color: rgba(170, 125, 80, 0.3);
|
||||
background: #fff4ea;
|
||||
color: #1a1410;
|
||||
}
|
||||
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-tab {
|
||||
color: #6b5c4e;
|
||||
}
|
||||
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-tab:hover {
|
||||
color: #1a1410;
|
||||
background: rgba(160, 115, 72, 0.07);
|
||||
}
|
||||
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-tab.active,
|
||||
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-tab[data-status="active"] {
|
||||
color: #1a1410;
|
||||
background: rgba(160, 115, 72, 0.1);
|
||||
}
|
||||
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-tab-secondary {
|
||||
opacity: 1;
|
||||
color: #7d6d5f;
|
||||
}
|
||||
[data-theme-resolved="light"] .app-shell:has(.home-v2-main) .navbar-tab-secondary:hover {
|
||||
color: #1a1410;
|
||||
}
|
||||
|
||||
/* Light — carousel nav arrows */
|
||||
[data-theme-resolved="light"] .home-v2-carousel-nav button {
|
||||
background: #fff8f2;
|
||||
@@ -9089,6 +9134,44 @@ html.theme-transition::view-transition-new(theme) {
|
||||
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Home v2 nav contrast */
|
||||
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar {
|
||||
background: rgba(6, 6, 8, 0.9);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .brand,
|
||||
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .brand-name {
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-search-home {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-search-home:hover {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-tab {
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
}
|
||||
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-tab:hover {
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-tab.active,
|
||||
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-tab[data-status="active"] {
|
||||
color: rgba(255, 255, 255, 0.98);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-tab-secondary {
|
||||
opacity: 1;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
[data-theme-resolved="dark"] .app-shell:has(.home-v2-main) .navbar-tab-secondary:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
HOME V2 — Semi-rounded radius (Claw × Hub midpoint)
|
||||
@@ -9096,19 +9179,45 @@ html.theme-transition::view-transition-new(theme) {
|
||||
Claw: 6/8/12 • Hub: 1/2/2 • Ours: 4/7/10
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Override the home-v2 radius tokens — unified 8px */
|
||||
/* Override the home-v2 radius tokens */
|
||||
.home-v2-main {
|
||||
--hv2-radius-sm: 8px;
|
||||
--hv2-radius-md: 8px;
|
||||
--hv2-radius-lg: 8px;
|
||||
--hv2-radius-sm: 4px;
|
||||
--hv2-radius-md: 7px;
|
||||
--hv2-radius-lg: 10px;
|
||||
}
|
||||
|
||||
.home-v2-search-bar { border-radius: 8px; }
|
||||
.home-v2-search-go { border-radius: 8px; }
|
||||
.home-v2-search-bar kbd { border-radius: 8px; }
|
||||
.home-v2-c-icon { border-radius: 8px; }
|
||||
.home-v2-c-tag { border-radius: 8px; }
|
||||
.home-v2-cat-icon { border-radius: 8px; }
|
||||
/* Search bar — was 16px, now semi-rounded */
|
||||
.home-v2-search-bar {
|
||||
border-radius: 10px;
|
||||
}
|
||||
/* Search button — was 12px */
|
||||
.home-v2-search-go {
|
||||
border-radius: 7px;
|
||||
}
|
||||
/* kbd badge — was 5px */
|
||||
.home-v2-search-bar kbd {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Carousel cards — picks up var(--hv2-radius-lg) = 10px ✓ */
|
||||
/* Trending cards — picks up var(--hv2-radius-lg) = 10px ✓ */
|
||||
|
||||
/* Carousel card icon — was 10px */
|
||||
.home-v2-c-icon {
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
/* Tag pills — was 100px (full pill), bring to semi-rounded pill */
|
||||
.home-v2-c-tag {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
/* Category icon — was 11px */
|
||||
.home-v2-cat-icon {
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
/* Install buttons — picks up var(--hv2-radius-sm) = 4px ✓ */
|
||||
|
||||
/* Suggestion pills — were likely pill-shaped, soften */
|
||||
.home-v2-suggestion {
|
||||
|
||||
Reference in New Issue
Block a user