Compare commits

...
Author SHA1 Message Date
Val Alexander f7c72bb56c docs: add logo replacement design spec 2026-04-21 13:35:18 -05:00
Val Alexander 4787be4eb1 Refresh Open Graph image (#1754)
* Refresh OG image and bust cache version

- Replace the social preview artwork with a new branded SVG and updated PNG
- Add a versioned og image URL in the root head tags to ensure the new asset is served

* Refresh OG image design

- Redesign the social preview graphic for the new ClawHub branding
- Bump the OG image version so the updated asset is served

* fix: refine clawhub og image

* fix: center og logo layout

* fix: emphasize clawhub branding in og image

* Refresh OG image branding

- Update Open Graph artwork and logo asset
- Adjust root metadata to use the new social preview image

* fix: refine clawhub og image

* fix: tighten og image layout

* fix: remove og logo panel

* fix: reduce og logo scale

* fix: align og image to new comp
2026-04-20 21:59:08 -05:00
Gustavo Madeira Santana 89246f1927 chore(ui): remove gap before hero cycled words 2026-04-19 13:25:56 -04:00
Val Alexander f4ddccbead enchance: mobile skills ux (#1737) 2026-04-18 20:10:03 -05:00
Val Alexander 3cafcbf873 Mobile search icon + system theme on first load
- Initialize root theme data from stored selection before paint
- Hide the search label on mobile and tighten button padding
2026-04-18 18:40:56 -05:00
Val Alexander 13064a7897 Merge pull request #1731 from openclaw/okcode/fix-mobile-search-button
Fix mobile header branding and add Home link
2026-04-18 17:52:29 -05:00
Val Alexander 194c22f4dd Add branded mobile nav header
- Show the logo mark in the mobile drawer title
- Tighten mobile suggestion spacing on small screens
- Add test coverage for the branded mobile nav header
2026-04-18 17:50:21 -05:00
Val Alexander a693b945fa Add Home link to mobile header navigation
- Insert a Home entry at the top of the mobile menu
- Update header tests to cover the new menu order
2026-04-18 17:40:22 -05:00
Val Alexander 9bef672541 Merge branch 'okcode/polished-card-icons-logo' 2026-04-18 17:28:51 -05:00
Val Alexander 9551cac37b Merge pull request #1729 from openclaw/okcode/fix-settings-update
Stabilize preferences sync and keep diff editor mounted
2026-04-18 17:25:52 -05:00
Val Alexander eb4138fbb3 fix: harden preferences storage sync 2026-04-18 17:24:31 -05:00
Val Alexanderandgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> 5fbead624b Update src/lib/preferences.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-18 17:17:18 -05:00
Val Alexander 35094177e6 Keep diff editor mounted when switching view mode
- Remove the diff editor remount on inline vs side-by-side toggles
- Add a regression test to verify the editor stays mounted
2026-04-18 17:04:30 -05:00
Val Alexander c3314c2d01 Stabilize preference snapshots and storage sync
- Cache localStorage reads to avoid redundant snapshot churn
- Sync updates across tabs and add coverage for preference re-renders
2026-04-18 17:03:15 -05:00
17 changed files with 1002 additions and 129 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
</p>
ClawHub is the **public skill registry for Clawdbot**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
ClawHub is the **public skill registry for OpenClaw**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
It's designed for fast browsing + a CLI-friendly API, with moderation hooks and vector search.
It also now exposes a native **OpenClaw package catalog** for code plugins and bundle plugins.
@@ -0,0 +1,182 @@
# Logo Replacement Design
Date: 2026-04-21
Topic: Comprehensive logo replacement using the provided lobster artwork
## Summary
Replace every current application logo surface with the user-provided lobster artwork while preserving the existing UI layout and copy. This includes in-app logo images, favicon and install icon assets, and manifest/head wiring. The existing wide social preview image `public/og.png` remains unchanged. Instead, `public/og-logo.png` is included in the replacement asset pack as a standalone logo export and is not wired into site metadata.
## Goals
- Replace all current logo imagery with the provided lobster art.
- Preserve existing layout structure in header, mobile navigation, and hero content.
- Provide dedicated asset files for browser, install, and app surfaces rather than relying on one large source image everywhere.
- Keep runtime references stable where possible by replacing existing filenames in place.
- Improve browser/device logo behavior by adding standard favicon and touch icon variants.
## Non-Goals
- No header, navigation, or hero layout redesign.
- No typography or copy changes to the `ClawHub` wordmark text.
- No change to the existing social preview card asset `public/og.png`.
- No full vector redraw of the lobster artwork from scratch.
## Scope
### In Scope
- Replace:
- `public/clawd-logo.png`
- `public/clawd-mark.png`
- `public/logo192.png`
- `public/logo512.png`
- `public/favicon.ico`
- Add or update:
- `public/favicon-16x16.png`
- `public/favicon-32x32.png`
- `public/apple-touch-icon.png`
- `public/logo.jpg`
- `public/logo.svg`
- `public/og-logo.png`
- Update runtime/browser metadata:
- root document link tags in `src/routes/__root.tsx`
- `public/manifest.json`
### Out of Scope
- `public/og.png`
- Any route-level social metadata currently using `og.png`
- Any non-logo artwork or unrelated illustration assets
## Current State
- The app currently references `public/clawd-logo.png` in the desktop and mobile header.
- The homepage hero references `public/clawd-mark.png`.
- The root document exposes `/favicon.ico`, `/logo192.png`, and `/manifest.json`.
- The web app manifest references `favicon.ico`, `logo192.png`, and `logo512.png`.
- The site-wide OG metadata still references `og.png`.
## Recommended Approach
Use the provided lobster image as the master artwork and derive a small asset pack tailored to each output surface.
Why this approach:
- It satisfies the request to replace the logo everywhere it appears.
- It avoids visual degradation from blindly reusing one oversized raster in tiny favicon contexts.
- It minimizes application code changes by preserving the established filenames used by the UI.
## Asset Plan
### Master Asset
Create one high-resolution square source derived from the attached lobster artwork. This will be the basis for all exported formats.
### Replacement Assets
- `clawd-logo.png`
- High-resolution square PNG used by header/mobile brand image references.
- `clawd-mark.png`
- High-resolution square PNG used by hero/logo-only surfaces.
- `logo192.png`
- 192×192 install icon.
- `logo512.png`
- 512×512 install icon.
- `favicon.ico`
- Multi-size favicon generated from the same master for browser tab use.
- `favicon-16x16.png`
- Explicit raster favicon for browsers that prefer PNG.
- `favicon-32x32.png`
- Explicit raster favicon for higher-density tab/bookmark use.
- `apple-touch-icon.png`
- 180×180 touch icon for iOS home screen usage.
- `logo.jpg`
- Flattened JPEG export for contexts where a non-transparent logo file is useful.
- `logo.svg`
- SVG wrapper asset that embeds the logo image in an SVG container so an SVG logo file exists for downstream usage without falsely claiming the art is natively vector.
- `og-logo.png`
- Logo-focused branded raster asset retained separately from the existing wide social card `og.png`.
## Runtime Wiring
### Application UI
- Keep existing JSX references to `clawd-logo.png` and `clawd-mark.png` unless a clearer dedicated asset path becomes necessary.
- Do not replace image elements with text or SVG components.
### Root Head Tags
Update `src/routes/__root.tsx` to use dedicated icon assets:
- `rel="icon"` should include PNG favicon variants in addition to the ICO.
- `rel="apple-touch-icon"` should point to `apple-touch-icon.png`.
- `rel="manifest"` remains `manifest.json`.
- OG/Twitter metadata remains wired to `og.png` and is not changed.
### Web App Manifest
Update `public/manifest.json` so install surfaces reference the replacement icon assets. Keep the manifest conservative and omit maskable-specific `purpose` values for this change.
## Data Flow
1. Start from the provided lobster artwork.
2. Export optimized raster variants for each target size.
3. Replace or add files in `public/`.
4. Update root document links and manifest entries.
5. Build the app and verify the logo surfaces still render without layout regressions.
## Error Handling And Risks
### Small-Size Legibility
Risk: the artwork is detailed and may lose clarity at favicon sizes.
Mitigation:
- Generate dedicated 16×16 and 32×32 outputs instead of relying only on browser downscaling.
- Prefer the ICO plus PNG favicon set to maximize compatibility.
### Raster-As-Vector Expectations
Risk: a pure SVG redraw would be time-consuming and subjective.
Mitigation:
- Provide `logo.svg` as an SVG container asset, while using raster files for browser/runtime surfaces that need visual fidelity.
### Unintended Social Preview Changes
Risk: a broad asset refresh accidentally changes OG behavior.
Mitigation:
- Explicitly leave `og.png` and its metadata references untouched.
- Treat `og-logo.png` as a separate logo asset only.
## Testing And Verification
- Confirm the generated files exist in `public/` with expected dimensions.
- Run the production build to ensure asset references still resolve.
- Spot-check the following surfaces:
- desktop header brand image
- mobile navigation brand image
- homepage hero lobster image
- browser favicon and touch icon wiring
- manifest icon references
- Verify that `og.png` remains unchanged and the site metadata still references it.
## Implementation Notes
- Use minimal code churn: replace files in place where existing paths are already correct.
- Add new icon files only where they improve browser/device handling.
- Keep the change tightly scoped to branding assets and metadata.
## Acceptance Criteria
- Every current application logo surface displays the provided lobster artwork instead of the previous brand image.
- Favicon, touch icon, and install icons resolve to replacement assets.
- Header/mobile/hero layout remains unchanged.
- `og.png` is not modified.
- `og-logo.png` exists as part of the updated asset pack.
- The app builds successfully after the change.
+135
View File
@@ -0,0 +1,135 @@
import { expect, test } from "@playwright/test";
import { expectHealthyPage, trackRuntimeErrors } from "./helpers/runtimeErrors";
// Only run in mobile projects — skip on desktop
test.beforeEach(({}, testInfo) => {
test.skip(
!testInfo.project.name.includes("mobile"),
"mobile-only test",
);
});
test("browse page has no horizontal overflow on mobile", async ({ page }) => {
const errors = trackRuntimeErrors(page);
await page.goto("/skills?sort=downloads", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: /^Skills/ })).toBeVisible();
await expect(page.locator(".skill-card, .skill-list-item").first()).toBeVisible();
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
await expectHealthyPage(page, errors);
});
test("browse sidebar toggle opens and closes filters", async ({ page }) => {
const errors = trackRuntimeErrors(page);
await page.goto("/skills?sort=downloads", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: /^Skills/ })).toBeVisible();
const filterButton = page.getByRole("button", { name: "Toggle filters" });
await expect(filterButton).toBeVisible();
// Sidebar should be hidden initially
const sidebar = page.locator(".browse-sidebar");
await expect(sidebar).not.toBeVisible();
// Open sidebar
await filterButton.click();
await expect(sidebar).toBeVisible();
// Close sidebar
await filterButton.click();
await expect(sidebar).not.toBeVisible();
await expectHealthyPage(page, errors);
});
test("card grid fits within viewport on mobile", async ({ page }) => {
const errors = trackRuntimeErrors(page);
await page.goto("/skills?sort=downloads&view=cards", { waitUntil: "domcontentloaded" });
await expect(page.locator(".skill-card").first()).toBeVisible();
const card = page.locator(".skill-card").first();
const cardBox = await card.boundingBox();
const viewport = page.viewportSize()!;
// Card should not exceed viewport width
expect(cardBox!.width).toBeLessThanOrEqual(viewport.width);
await expectHealthyPage(page, errors);
});
test("skill detail page has no horizontal overflow on mobile", async ({ page, request }) => {
const errors = trackRuntimeErrors(page);
const response = await request.get("/api/v1/skills/gifgrep");
test.skip(!response.ok(), "gifgrep fixture missing");
const payload = (await response.json()) as {
owner?: { handle?: string | null };
skill?: { slug?: string | null; displayName?: string | null };
};
const ownerHandle = payload.owner?.handle?.trim();
const slug = payload.skill?.slug?.trim();
test.skip(!ownerHandle || !slug || !payload.skill?.displayName, "fixture missing owner handle, slug, or displayName");
await page.goto(`/${ownerHandle}/${slug}`, { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: payload.skill!.displayName! }),
).toBeVisible();
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
await expectHealthyPage(page, errors);
});
test("detail tabs are scrollable and touch-friendly on mobile", async ({ page, request }) => {
const errors = trackRuntimeErrors(page);
const response = await request.get("/api/v1/skills/gifgrep");
test.skip(!response.ok(), "gifgrep fixture missing");
const payload = (await response.json()) as {
owner?: { handle?: string | null };
skill?: { slug?: string | null };
};
const ownerHandle = payload.owner?.handle?.trim();
const slug = payload.skill?.slug?.trim();
test.skip(!ownerHandle || !slug, "fixture missing");
await page.goto(`/${ownerHandle}/${slug}`, { waitUntil: "domcontentloaded" });
// All standard tabs should be accessible (even if scrolled)
for (const tabName of ["README", "Files", "Versions"]) {
const tab = page.getByRole("button", { name: tabName });
await tab.scrollIntoViewIfNeeded();
await expect(tab).toBeVisible();
// Touch target should be at least 44px
const box = await tab.boundingBox();
expect(box!.height).toBeGreaterThanOrEqual(44);
}
await expectHealthyPage(page, errors);
});
test("search input font size prevents iOS zoom", async ({ page }) => {
const errors = trackRuntimeErrors(page);
await page.goto("/skills?sort=downloads", { waitUntil: "domcontentloaded" });
const input = page.locator(".browse-search-input");
await expect(input).toBeVisible();
const fontSize = await input.evaluate((el) => getComputedStyle(el).fontSize);
// iOS Safari zooms the page when an input has font-size below 16px
expect(parseFloat(fontSize)).toBeGreaterThanOrEqual(16);
await expectHealthyPage(page, errors);
});
+8
View File
@@ -29,5 +29,13 @@ export default defineConfig({
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "mobile-chrome",
use: { ...devices["Pixel 7"] },
},
{
name: "mobile-safari",
use: { ...devices["iPhone 14"] },
},
],
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 281 KiB

+102
View File
@@ -0,0 +1,102 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="bgGlowRight" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(1048 328) rotate(180) scale(358 260)">
<stop stop-color="#7B1F18" stop-opacity="0.58"/>
<stop offset="0.45" stop-color="#431210" stop-opacity="0.26"/>
<stop offset="1" stop-color="#030305" stop-opacity="0"/>
</radialGradient>
<radialGradient id="bgGlowBottom" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(192 610) rotate(-90) scale(180 420)">
<stop stop-color="#A12A1D" stop-opacity="0.2"/>
<stop offset="1" stop-color="#030305" stop-opacity="0"/>
</radialGradient>
<linearGradient id="frameStroke" x1="40" y1="106" x2="1146" y2="530" gradientUnits="userSpaceOnUse">
<stop stop-color="#6D3437" stop-opacity="0.8"/>
<stop offset="0.55" stop-color="#DF5D35" stop-opacity="0.36"/>
<stop offset="1" stop-color="#FF6D39" stop-opacity="0.9"/>
</linearGradient>
<linearGradient id="frameGlow" x1="164" y1="164" x2="1114" y2="476" gradientUnits="userSpaceOnUse">
<stop stop-color="#130D10"/>
<stop offset="0.5" stop-color="#170B0E"/>
<stop offset="1" stop-color="#261011"/>
</linearGradient>
<linearGradient id="logoStroke" x1="112" y1="140" x2="398" y2="430" gradientUnits="userSpaceOnUse">
<stop stop-color="#4C2A28" stop-opacity="0.55"/>
<stop offset="1" stop-color="#E05831" stop-opacity="0.28"/>
</linearGradient>
<linearGradient id="searchStroke" x1="146" y1="480" x2="1068" y2="480" gradientUnits="userSpaceOnUse">
<stop stop-color="#7F342A" stop-opacity="0.65"/>
<stop offset="1" stop-color="#FF6F37" stop-opacity="0.85"/>
</linearGradient>
<linearGradient id="buttonFill" x1="838" y1="445" x2="1084" y2="510" gradientUnits="userSpaceOnUse">
<stop stop-color="#D55335"/>
<stop offset="1" stop-color="#EB6A3E"/>
</linearGradient>
<filter id="softBlur" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="20"/>
</filter>
<filter id="glowBlur" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="10"/>
</filter>
</defs>
<rect width="1200" height="630" fill="#030305"/>
<rect width="1200" height="630" fill="url(#bgGlowRight)"/>
<rect width="1200" height="630" fill="url(#bgGlowBottom)"/>
<g opacity="0.22">
<circle cx="998" cy="406" r="1.8" fill="#FF7649"/>
<circle cx="1036" cy="446" r="1.4" fill="#FF7649"/>
<circle cx="1088" cy="492" r="1.2" fill="#FF7649"/>
<circle cx="1116" cy="540" r="1.1" fill="#FF7649"/>
<circle cx="964" cy="502" r="1.3" fill="#FF7649"/>
<circle cx="880" cy="528" r="1.3" fill="#FF7649"/>
<circle cx="716" cy="452" r="1.1" fill="#FF7649"/>
<circle cx="622" cy="396" r="1.4" fill="#FF7649"/>
<circle cx="188" cy="558" r="1.3" fill="#FF7649"/>
<circle cx="152" cy="580" r="1.1" fill="#FF7649"/>
<circle cx="92" cy="594" r="1.4" fill="#FF7649"/>
</g>
<path d="M870 146C990 166 1082 230 1142 328" stroke="#AA3D2B" stroke-opacity="0.16" stroke-width="2"/>
<path d="M926 190C1036 234 1108 306 1168 420" stroke="#AA3D2B" stroke-opacity="0.12" stroke-width="2"/>
<path d="M1044 374H1200" stroke="#B74A36" stroke-opacity="0.28" stroke-width="2"/>
<path d="M24 522H164" stroke="#B74A36" stroke-opacity="0.22" stroke-width="2"/>
<rect x="42" y="108" width="1092" height="430" rx="42" fill="url(#frameGlow)"/>
<rect x="42.75" y="108.75" width="1090.5" height="428.5" rx="41.25" stroke="url(#frameStroke)" stroke-width="1.5"/>
<rect x="113" y="148" width="292" height="292" rx="38" fill="#09090C"/>
<rect x="113.75" y="148.75" width="290.5" height="290.5" rx="37.25" stroke="url(#logoStroke)" stroke-width="1.5"/>
<ellipse cx="1118" cy="180" rx="86" ry="42" fill="#FF6532" fill-opacity="0.18" filter="url(#glowBlur)"/>
<ellipse cx="1018" cy="328" rx="208" ry="164" fill="#8A2218" fill-opacity="0.12" filter="url(#softBlur)"/>
<ellipse cx="96" cy="532" rx="48" ry="10" fill="#FF5E35" fill-opacity="0.24" filter="url(#softBlur)"/>
<ellipse cx="572" cy="494" rx="302" ry="12" fill="#FF5E35" fill-opacity="0.12" filter="url(#softBlur)"/>
<image href="clawd-logo.png" x="124" y="158" width="270" height="270" preserveAspectRatio="xMidYMid meet"/>
<text x="500" y="256" fill="#F8EEE8" font-size="88" font-weight="900" letter-spacing="-4.2" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">ClawHub.ai</text>
<text x="500" y="338" fill="#F8EEE8" font-size="42" font-weight="800" letter-spacing="-1.2" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">Equip. Install. <tspan fill="#FF6236">Build.</tspan></text>
<text x="500" y="390" fill="#E1D4CF" font-size="23" font-weight="500" letter-spacing="-0.15" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">
<tspan x="500" dy="0">Developer tools and agent skills</tspan>
<tspan x="500" dy="28">for your next project.</tspan>
</text>
<g>
<rect x="148" y="432" width="924" height="104" rx="31" fill="#11090D"/>
<rect x="148.75" y="432.75" width="922.5" height="102.5" rx="30.25" stroke="url(#searchStroke)" stroke-width="1.5"/>
<circle cx="220" cy="484" r="19" stroke="#FFF9F3" stroke-width="5"/>
<line x1="233" y1="497" x2="249" y2="513" stroke="#FFF9F3" stroke-width="5" stroke-linecap="round"/>
<text x="272" y="495" fill="#DCD0CB" font-size="31" font-weight="600" letter-spacing="-0.4" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">What are you looking for?</text>
<rect x="824" y="450" width="258" height="66" rx="21" fill="url(#buttonFill)"/>
<text x="953" y="494" text-anchor="middle" fill="#FFF8F1" font-size="28" font-weight="800" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">Search tools</text>
</g>
<g>
<rect x="292" y="574" width="214" height="44" rx="22" fill="#1B1317" stroke="#3A292C"/>
<text x="399" y="603" text-anchor="middle" fill="#EEE3DF" font-size="18" font-weight="500" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">self-improving</text>
<rect x="530" y="574" width="248" height="44" rx="22" fill="#1B1317" stroke="#3A292C"/>
<text x="654" y="603" text-anchor="middle" fill="#EEE3DF" font-size="18" font-weight="500" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">GitHub integration</text>
<rect x="802" y="574" width="180" height="44" rx="22" fill="#1B1317" stroke="#3A292C"/>
<text x="892" y="603" text-anchor="middle" fill="#EEE3DF" font-size="18" font-weight="500" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">dashboard</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

+130 -94
View File
@@ -9,149 +9,185 @@ const siteModeMock = vi.fn(() => "souls");
const navigateMock = vi.fn();
vi.mock("@tanstack/react-router", () => ({
Link: (props: {
children: ReactNode;
className?: string;
hash?: string;
to?: string;
}) => (
<a href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`} className={props.className}>
{props.children}
</a>
),
useLocation: () => ({ pathname: "/" }),
useNavigate: () => navigateMock,
Link: (props: {
children: ReactNode;
className?: string;
hash?: string;
to?: string;
}) => (
<a
href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`}
className={props.className}
>
{props.children}
</a>
),
useLocation: () => ({ pathname: "/" }),
useNavigate: () => navigateMock,
}));
vi.mock("@convex-dev/auth/react", () => ({
useAuthActions: () => ({
signIn: vi.fn(),
signOut: vi.fn(),
}),
useAuthActions: () => ({
signIn: vi.fn(),
signOut: vi.fn(),
}),
}));
const authStatusMock = vi.fn(() => ({
isAuthenticated: false,
isLoading: false,
me: null,
isAuthenticated: false,
isLoading: false,
me: null,
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => authStatusMock(),
useAuthStatus: () => authStatusMock(),
}));
const setThemeMock = vi.fn();
const setModeMock = vi.fn();
vi.mock("../lib/theme", () => ({
applyTheme: vi.fn(),
THEME_OPTIONS: [
{ value: "claw", label: "Claw", description: "" },
{ value: "hub", label: "Hub", description: "" },
],
useThemeMode: () => ({
theme: "hub",
mode: "system",
setTheme: setThemeMock,
setMode: setModeMock,
}),
applyTheme: vi.fn(),
THEME_OPTIONS: [
{ value: "claw", label: "Claw", description: "" },
{ value: "hub", label: "Hub", description: "" },
],
useThemeMode: () => ({
theme: "hub",
mode: "system",
setTheme: setThemeMock,
setMode: setModeMock,
}),
}));
vi.mock("../lib/theme-transition", () => ({
startThemeTransition: ({
setTheme,
nextTheme,
}: {
setTheme: (value: string) => void;
nextTheme: string;
}) => setTheme(nextTheme),
startThemeTransition: ({
setTheme,
nextTheme,
}: {
setTheme: (value: string) => void;
nextTheme: string;
}) => setTheme(nextTheme),
}));
vi.mock("../lib/useAuthError", () => ({
setAuthError: vi.fn(),
useAuthError: () => ({
error: null,
clear: vi.fn(),
}),
setAuthError: vi.fn(),
useAuthError: () => ({
error: null,
clear: vi.fn(),
}),
}));
vi.mock("../lib/roles", () => ({
isModerator: () => false,
isModerator: () => false,
}));
vi.mock("../lib/site", () => ({
getClawHubSiteUrl: () => "https://clawhub.ai",
getSiteMode: () => siteModeMock(),
getSiteName: () => "OnlyCrabs",
getClawHubSiteUrl: () => "https://clawhub.ai",
getSiteMode: () => siteModeMock(),
getSiteName: () => "OnlyCrabs",
}));
vi.mock("../lib/gravatar", () => ({
gravatarUrl: vi.fn(),
gravatarUrl: vi.fn(),
}));
vi.mock("../components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuSeparator: () => <hr />,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenu: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuItem: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuSeparator: () => <hr />,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock("../components/ui/toggle-group", () => ({
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ToggleGroupItem: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
<button type="button">{children}</button>
),
}));
describe("Header", () => {
it("hides Packages navigation in soul mode on mobile and desktop", () => {
siteModeMock.mockReturnValue("souls");
it("hides Packages navigation in soul mode on mobile and desktop", () => {
siteModeMock.mockReturnValue("souls");
render(<Header />);
render(<Header />);
expect(screen.queryByText("Packages")).toBeNull();
});
expect(screen.queryByText("Packages")).toBeNull();
});
it("renders direct desktop theme family controls and plain Skills tab", () => {
siteModeMock.mockReturnValue("skills");
setThemeMock.mockClear();
setModeMock.mockClear();
it("renders direct desktop theme family controls and plain Skills tab", () => {
siteModeMock.mockReturnValue("skills");
setThemeMock.mockClear();
setModeMock.mockClear();
render(<Header />);
render(<Header />);
expect(screen.getByRole("button", { name: /Cycle theme mode/i })).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);
expect(screen.getAllByText("Users")).toHaveLength(1);
expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy();
expect(
screen.getByRole("button", { name: /Cycle theme mode/i }),
).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);
expect(screen.getAllByText("Users")).toHaveLength(1);
expect(
screen.getByPlaceholderText("Search skills, plugins, users"),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
expect(setModeMock).toHaveBeenCalledWith("light");
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
expect(setModeMock).toHaveBeenCalledWith("light");
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Users")).toHaveLength(2);
});
expect(screen.getAllByText("Home")).toHaveLength(1);
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Users")).toHaveLength(2);
});
it("routes soul-mode header searches to the souls browse page", () => {
siteModeMock.mockReturnValue("souls");
navigateMock.mockReset();
it("shows Home above Skills in the mobile menu", () => {
siteModeMock.mockReturnValue("skills");
render(<Header />);
render(<Header />);
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
target: { value: "angler" },
});
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
expect(navigateMock).toHaveBeenCalledWith({
to: "/souls",
search: {
q: "angler",
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
},
});
});
expect(document.querySelector(".mobile-nav-brand-mark-image")).toBeTruthy();
const labels = Array.from(
document.querySelectorAll(".mobile-nav-section .mobile-nav-link"),
)
.map((element) => element.textContent?.trim())
.filter((label): label is string => Boolean(label));
expect(labels.slice(0, 2)).toEqual(["Home", "Skills"]);
});
it("routes soul-mode header searches to the souls browse page", () => {
siteModeMock.mockReturnValue("souls");
navigateMock.mockReset();
render(<Header />);
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
target: { value: "angler" },
});
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
expect(navigateMock).toHaveBeenCalledWith({
to: "/souls",
search: {
q: "angler",
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
},
});
});
});
+18 -1
View File
@@ -129,12 +129,29 @@ export default function Header() {
</button>
<SheetContent side="left" className="mobile-nav-sheet">
<SheetHeader className="pr-10">
<SheetTitle>{siteName}</SheetTitle>
<SheetTitle>
<span className="mobile-nav-brand">
<span className="mobile-nav-brand-mark" aria-hidden="true">
<img
src="/clawd-logo.png"
alt=""
aria-hidden="true"
className="mobile-nav-brand-mark-image"
/>
</span>
<span className="mobile-nav-brand-name">{siteName}</span>
</span>
</SheetTitle>
<SheetDescription>
Browse sections, switch theme, and access account actions.
</SheetDescription>
</SheetHeader>
<div className="mobile-nav-section">
<SheetClose asChild>
<Link to="/" className="mobile-nav-link">
Home
</Link>
</SheetClose>
{isSoulMode ? (
<SheetClose asChild>
<a href={clawHubUrl} className="mobile-nav-link">
+1 -1
View File
@@ -143,7 +143,7 @@ export function SkillCommentsPanel({ skillId, isAuthenticated, me }: SkillCommen
comments.map((entry) => (
<div
key={entry.comment._id}
className="flex gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-3"
className="comment-entry flex gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-3"
>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<strong className="text-sm">
+60 -3
View File
@@ -1,10 +1,13 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useEffect } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { SkillDiffCard } from "./SkillDiffCard";
const getFileTextMock = vi.fn();
let diffEditorMounts = 0;
let diffEditorUnmounts = 0;
vi.mock("convex/react", () => ({
useAction: () => getFileTextMock,
@@ -18,15 +21,37 @@ vi.mock("@monaco-editor/react", () => ({
className?: string;
options?: { renderSideBySide?: boolean; useInlineViewWhenSpaceIsLimited?: boolean };
}) => (
<MockDiffEditor
className={className}
options={options}
/>
),
useMonaco: () => null,
}));
function MockDiffEditor({
className,
options,
}: {
className?: string;
options?: { renderSideBySide?: boolean; useInlineViewWhenSpaceIsLimited?: boolean };
}) {
useEffect(() => {
diffEditorMounts += 1;
return () => {
diffEditorUnmounts += 1;
};
}, []);
return (
<div
className={className}
data-inline-fallback={String(options?.useInlineViewWhenSpaceIsLimited)}
data-side-by-side={String(options?.renderSideBySide)}
data-testid="diff-editor"
/>
),
useMonaco: () => null,
}));
);
}
function installMatchMedia(matches: boolean) {
Object.defineProperty(window, "matchMedia", {
@@ -65,6 +90,8 @@ describe("SkillDiffCard", () => {
beforeEach(() => {
getFileTextMock.mockReset();
getFileTextMock.mockResolvedValue({ text: "content" });
diffEditorMounts = 0;
diffEditorUnmounts = 0;
});
it("defaults to inline mode on narrow screens", async () => {
@@ -108,4 +135,34 @@ describe("SkillDiffCard", () => {
});
expect(screen.getByRole("button", { name: "Side-by-side" }).className).toContain("is-active");
});
it("keeps the diff editor mounted when toggling view mode", async () => {
installMatchMedia(false);
render(
<SkillDiffCard
skill={skill}
versions={[
makeVersion("skillVersions:1", "1.0.1"),
makeVersion("skillVersions:2", "1.0.2"),
]}
/>,
);
await waitFor(() => {
expect(screen.getByTestId("diff-editor")).toBeTruthy();
});
expect(diffEditorMounts).toBe(1);
expect(diffEditorUnmounts).toBe(0);
fireEvent.click(screen.getByRole("button", { name: "Inline" }));
await waitFor(() => {
expect(screen.getByTestId("diff-editor").getAttribute("data-side-by-side")).toBe("false");
});
expect(diffEditorMounts).toBe(1);
expect(diffEditorUnmounts).toBe(0);
});
});
+1 -2
View File
@@ -38,7 +38,7 @@ type SizeWarning = {
};
const EMPTY_DIFF_TEXT = "";
const MOBILE_DIFF_BREAKPOINT = 860;
const MOBILE_DIFF_BREAKPOINT = 768;
function getDefaultViewMode() {
if (typeof window === "undefined") return "split";
@@ -402,7 +402,6 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
) : (
<ClientOnly fallback={<div className="diff-empty">Preparing diff</div>}>
<DiffEditor
key={`diff-${viewMode}`}
className={`diff-monaco diff-monaco-${viewMode}`}
original={leftText}
modified={rightText}
+121
View File
@@ -0,0 +1,121 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { usePreferences, getStoredPreferencesSnapshot } from "./preferences";
const PREFERENCES_KEY = "clawhub-preferences";
function PreferencesProbe() {
const { preferences, updatePreference, isAdvancedMode } = usePreferences();
return (
<div>
<div data-testid="density">{preferences.layoutDensity}</div>
<div data-testid="advanced">{String(isAdvancedMode)}</div>
<button
type="button"
onClick={() => updatePreference("advancedMode", !preferences.advancedMode)}
>
Toggle advanced
</button>
</div>
);
}
describe("preferences store", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("returns a stable snapshot when storage has not changed", () => {
const first = getStoredPreferencesSnapshot();
const second = getStoredPreferencesSnapshot();
expect(first).toBe(second);
expect(first.layoutDensity).toBe("comfortable");
});
it("falls back to defaults when localStorage reads throw", () => {
const getItemSpy = vi
.spyOn(Storage.prototype, "getItem")
.mockImplementation(() => {
throw new DOMException("blocked", "SecurityError");
});
render(<PreferencesProbe />);
expect(screen.getByTestId("density").textContent).toBe("comfortable");
expect(screen.getByTestId("advanced").textContent).toBe("false");
getItemSpy.mockRestore();
});
it("resets to defaults when the preference key is cleared in another tab", () => {
window.localStorage.setItem(
PREFERENCES_KEY,
JSON.stringify({
advancedMode: true,
layoutDensity: "compact",
}),
);
render(<PreferencesProbe />);
expect(screen.getByTestId("advanced").textContent).toBe("true");
expect(screen.getByTestId("density").textContent).toBe("compact");
window.localStorage.removeItem(PREFERENCES_KEY);
act(() => {
const event = Object.assign(new Event("storage"), {
key: PREFERENCES_KEY,
newValue: null,
oldValue: JSON.stringify({
advancedMode: true,
layoutDensity: "compact",
}),
storageArea: window.localStorage,
});
window.dispatchEvent(event);
});
expect(screen.getByTestId("advanced").textContent).toBe("false");
expect(screen.getByTestId("density").textContent).toBe("comfortable");
});
it("re-renders cleanly after a preference update", () => {
window.localStorage.setItem(
PREFERENCES_KEY,
JSON.stringify({
advancedMode: false,
}),
);
render(<PreferencesProbe />);
expect(screen.getByTestId("advanced").textContent).toBe("false");
fireEvent.click(screen.getByRole("button", { name: /toggle advanced/i }));
expect(screen.getByTestId("advanced").textContent).toBe("true");
expect(JSON.parse(window.localStorage.getItem(PREFERENCES_KEY) ?? "{}")).toMatchObject({
advancedMode: true,
});
});
it("does not cache a preference change when storage writes fail", () => {
const setItemSpy = vi
.spyOn(Storage.prototype, "setItem")
.mockImplementation(() => {
throw new DOMException("quota", "QuotaExceededError");
});
render(<PreferencesProbe />);
expect(screen.getByTestId("advanced").textContent).toBe("false");
fireEvent.click(screen.getByRole("button", { name: /toggle advanced/i }));
expect(screen.getByTestId("advanced").textContent).toBe("false");
expect(window.localStorage.getItem(PREFERENCES_KEY)).toBeNull();
setItemSpy.mockRestore();
});
});
+79 -19
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useSyncExternalStore } from "react";
const PREFERENCES_KEY = "clawhub-preferences";
@@ -66,36 +66,95 @@ const defaultPreferences: UserPreferences = {
// Simple event emitter for cross-tab sync
const listeners = new Set<() => void>();
let cachedPreferencesRaw: string | null = null;
let cachedPreferencesSnapshot: UserPreferences = defaultPreferences;
let hasCachedPreferences = false;
let removeStorageListener: (() => void) | null = null;
function normalizePreferences(parsed: Partial<UserPreferences> | null): UserPreferences {
if (!parsed) return defaultPreferences;
return { ...defaultPreferences, ...parsed };
}
function parsePreferences(raw: string | null): UserPreferences {
if (!raw) return defaultPreferences;
try {
const parsed = JSON.parse(raw) as Partial<UserPreferences>;
return normalizePreferences(parsed);
} catch {
return defaultPreferences;
}
}
function readStoredPreferences(): UserPreferences {
if (typeof window === "undefined") return defaultPreferences;
try {
const stored = window.localStorage.getItem(PREFERENCES_KEY);
if (hasCachedPreferences && stored === cachedPreferencesRaw) {
return cachedPreferencesSnapshot;
}
cachedPreferencesRaw = stored;
cachedPreferencesSnapshot = parsePreferences(stored);
hasCachedPreferences = true;
return cachedPreferencesSnapshot;
} catch {
cachedPreferencesRaw = null;
cachedPreferencesSnapshot = defaultPreferences;
hasCachedPreferences = true;
return cachedPreferencesSnapshot;
}
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
if (typeof window !== "undefined" && !removeStorageListener) {
const handleStorage = (event: StorageEvent) => {
if (event.storageArea !== window.localStorage || event.key !== PREFERENCES_KEY) {
return;
}
cachedPreferencesRaw = event.newValue;
cachedPreferencesSnapshot = parsePreferences(event.newValue);
hasCachedPreferences = true;
notifyListeners();
};
window.addEventListener("storage", handleStorage);
removeStorageListener = () => {
window.removeEventListener("storage", handleStorage);
removeStorageListener = null;
};
}
return () => {
listeners.delete(listener);
if (listeners.size === 0 && removeStorageListener) {
removeStorageListener();
}
};
}
function notifyListeners() {
listeners.forEach((listener) => listener());
}
function getStoredPreferences(): UserPreferences {
if (typeof window === "undefined") return defaultPreferences;
try {
const stored = window.localStorage.getItem(PREFERENCES_KEY);
if (!stored) return defaultPreferences;
const parsed = JSON.parse(stored) as Partial<UserPreferences>;
return { ...defaultPreferences, ...parsed };
} catch {
return defaultPreferences;
}
}
function savePreferences(prefs: UserPreferences) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(PREFERENCES_KEY, JSON.stringify(prefs));
notifyListeners();
const serialized = JSON.stringify(prefs);
window.localStorage.setItem(PREFERENCES_KEY, serialized);
cachedPreferencesRaw = serialized;
cachedPreferencesSnapshot = prefs;
hasCachedPreferences = true;
} catch {
// Storage might be full or disabled
}
notifyListeners();
}
// Server snapshot for SSR
@@ -106,7 +165,7 @@ function getServerSnapshot(): UserPreferences {
export function usePreferences() {
const preferences = useSyncExternalStore(
subscribe,
getStoredPreferences,
readStoredPreferences,
getServerSnapshot
);
@@ -114,13 +173,13 @@ export function usePreferences() {
key: K,
value: UserPreferences[K]
) => {
const current = getStoredPreferences();
const current = readStoredPreferences();
const updated = { ...current, [key]: value };
savePreferences(updated);
}, []);
const updatePreferences = useCallback((updates: Partial<UserPreferences>) => {
const current = getStoredPreferences();
const current = readStoredPreferences();
const updated = { ...current, ...updates };
savePreferences(updated);
}, []);
@@ -163,3 +222,4 @@ export function usePreferences() {
}
export { defaultPreferences };
export { readStoredPreferences as getStoredPreferencesSnapshot };
+8 -1
View File
@@ -10,13 +10,15 @@ import Header from "../components/Header";
import { getSiteDescription, getSiteMode, getSiteName, getSiteUrlForMode } from "../lib/site";
import appCss from "../styles.css?url";
const OG_IMAGE_VERSION = "20260420-12";
export const Route = createRootRoute({
head: () => {
const mode = getSiteMode();
const siteName = getSiteName(mode);
const siteDescription = getSiteDescription(mode);
const siteUrl = getSiteUrlForMode(mode);
const ogImage = `${siteUrl}/og.png`;
const ogImage = `${siteUrl}/og.png?v=${OG_IMAGE_VERSION}`;
return {
meta: [
@@ -117,6 +119,11 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<html lang="en">
<head>
<HeadContent />
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var d=document.documentElement,s='clawhub-theme-selection',k='clawhub-theme',n='clawhub-theme-name',l='clawdhub-theme';var sel;try{var raw=localStorage.getItem(s);if(raw){sel=JSON.parse(raw)}}catch(e){}if(!sel){var m=localStorage.getItem(k),t=localStorage.getItem(n);if(m||t){sel={theme:t||'claw',mode:m||'system'}}else{var lg=localStorage.getItem(l);if(lg){var map={dark:'dark',light:'light',system:'system',defaultTheme:'dark',docsTheme:'light',lightTheme:'dark',landingTheme:'dark',newTheme:'dark',openknot:'dark',fieldmanual:'dark',clawdash:'light'};sel={theme:'claw',mode:map[lg]||'system'}}}}if(!sel)sel={theme:'claw',mode:'system'};var themes=['claw'],modes=['system','light','dark'];if(themes.indexOf(sel.theme)<0)sel.theme='claw';if(modes.indexOf(sel.mode)<0)sel.mode='system';var resolved=sel.mode==='system'?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):sel.mode;d.dataset.theme=resolved;d.dataset.themeResolved=resolved;d.dataset.themeMode=sel.mode;d.dataset.themeFamily=sel.theme;if(resolved==='dark')d.classList.add('dark');else d.classList.remove('dark')}catch(e){}})()`,
}}
/>
</head>
<body>
<AppProviders>
+2 -1
View File
@@ -438,7 +438,8 @@ function SkillsHome() {
/>
<kbd>/</kbd>
<button type="submit" className="home-v2-search-go">
Search <ArrowRight size={16} />
<span className="home-v2-search-go-label">Search</span>{" "}
<ArrowRight size={16} />
</button>
</form>
</div>
+154 -6
View File
@@ -981,6 +981,39 @@ code {
color: var(--ink-soft);
}
.mobile-nav-brand {
display: inline-flex;
align-items: center;
gap: 10px;
}
.mobile-nav-brand-mark {
width: 28px;
height: 28px;
border-radius: var(--r-sm);
display: grid;
place-items: center;
overflow: hidden;
background: transparent;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--ink) 8%, transparent),
0 1px 2px rgba(0, 0, 0, 0.08);
flex-shrink: 0;
}
.mobile-nav-brand-mark-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: var(--r-sm);
transform: translateZ(0);
}
.mobile-nav-brand-name {
display: inline-flex;
min-width: 0;
}
.mobile-nav-link {
display: inline-flex;
align-items: center;
@@ -1998,7 +2031,7 @@ code {
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr));
gap: 16px;
max-width: 100%;
}
@@ -3361,7 +3394,7 @@ code {
border: 1px solid var(--line);
background: var(--surface-muted);
text-align: right;
min-width: 150px;
min-width: 0;
}
.skill-version-label {
@@ -3412,7 +3445,7 @@ code {
.skill-hero-panels {
display: grid;
gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
}
.skill-panel {
@@ -3570,6 +3603,10 @@ code {
max-height: 220px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.diff-monaco {
max-height: 300px;
}
}
.diff-pill {
@@ -3724,6 +3761,15 @@ code {
border: 1px solid var(--line);
background: var(--surface-muted);
align-self: flex-start;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
max-width: 100%;
flex-shrink: 0;
}
.tab-header::-webkit-scrollbar {
display: none;
}
.tab-button {
@@ -3735,6 +3781,8 @@ code {
color: var(--ink-soft);
cursor: pointer;
min-height: 44px;
white-space: nowrap;
flex-shrink: 0;
}
.tab-button.is-active {
@@ -4163,9 +4211,18 @@ code {
}
.skill-hero-cta {
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
}
.skill-hero-cta .btn {
width: auto;
flex: 1 1 auto;
min-width: 0;
}
.tag-form {
grid-template-columns: 1fr;
align-items: stretch;
@@ -4207,6 +4264,14 @@ code {
row-gap: 8px;
}
.browse-search-input {
font-size: 16px;
}
.navbar-search-input {
font-size: 16px;
}
}
@media (max-width: 520px) {
@@ -5923,7 +5988,12 @@ html.theme-transition::view-transition-new(theme) {
}
.skill-hero-title h1 {
font-size: 1.35rem;
font-size: 1.25rem;
line-height: 1.3;
}
.skill-hero-note {
font-size: 0.82rem;
}
.card {
@@ -5934,6 +6004,43 @@ html.theme-transition::view-transition-new(theme) {
padding: 16px;
}
.detail-meta-bar {
padding: var(--space-3);
}
.meta-bar-stats {
gap: var(--space-3);
}
.skill-hero-sidebar-meta {
min-width: 0;
}
.file-viewer {
padding: 12px;
}
.file-viewer-body {
max-height: 260px;
}
.file-list-body {
max-height: 200px;
}
.comment-entry {
padding: var(--space-2);
}
.markdown pre {
font-size: 0.78rem;
}
.markdown img {
max-width: 100%;
height: auto;
}
.scan-result-row {
grid-template-columns: 1fr auto;
gap: 6px 10px;
@@ -6573,6 +6680,8 @@ html.theme-transition::view-transition-new(theme) {
@media (max-width: 560px) {
.skill-list-item {
grid-template-columns: 1fr;
padding: 14px 16px;
gap: 12px;
}
.marketplace-icon {
@@ -6669,6 +6778,7 @@ html.theme-transition::view-transition-new(theme) {
border-radius: var(--r-sm);
background: var(--surface);
cursor: pointer;
min-height: 44px;
color: var(--ink-soft);
}
@@ -6869,7 +6979,21 @@ html.theme-transition::view-transition-new(theme) {
@media (max-width: 760px) {
.browse-page {
padding: 16px 18px 40px;
padding: 16px 16px 40px;
}
.browse-page-search {
height: 44px;
}
.browse-results-toolbar {
flex-wrap: wrap;
gap: 8px;
}
.browse-view-btn {
min-height: 44px;
padding: var(--space-2) var(--space-3);
}
.browse-layout {
@@ -7845,7 +7969,7 @@ html.theme-transition::view-transition-new(theme) {
height: 1.15em;
display: flex;
align-items: center;
justify-content: center;
justify-content: left;
flex-shrink: 0;
white-space: nowrap;
}
@@ -8115,6 +8239,24 @@ html.theme-transition::view-transition-new(theme) {
color: var(--hv2-accent);
}
@media (max-width: 760px) {
.home-v2-suggestions {
gap: 6px;
margin-top: 12px;
}
.home-v2-suggestions-label {
font-size: 12px;
margin-right: 2px;
}
.home-v2-suggestion {
font-size: 12px;
padding: 5px 10px;
gap: 4px;
}
}
/* ═══ CAROUSEL ═══ */
.home-v2-carousel-section {
padding: 48px 0 0;
@@ -8696,6 +8838,12 @@ html.theme-transition::view-transition-new(theme) {
padding: 20px;
gap: 16px;
}
.home-v2-search-go-label {
display: none;
}
.home-v2-search-go {
padding: 13px 16px;
}
}
/* ═══ HOME V2 — Full-width overrides ═══ */