mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
enchance: mobile skills ux (#1737)
This commit is contained in:
@@ -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,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);
|
||||
});
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
+96
-5
@@ -2031,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%;
|
||||
}
|
||||
@@ -3394,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 {
|
||||
@@ -3445,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 {
|
||||
@@ -3603,6 +3603,10 @@ code {
|
||||
max-height: 220px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.diff-monaco {
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.diff-pill {
|
||||
@@ -3757,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 {
|
||||
@@ -3768,6 +3781,8 @@ code {
|
||||
color: var(--ink-soft);
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-button.is-active {
|
||||
@@ -4196,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;
|
||||
@@ -4240,6 +4264,14 @@ code {
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.browse-search-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.navbar-search-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
@@ -5956,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 {
|
||||
@@ -5967,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;
|
||||
@@ -6606,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 {
|
||||
@@ -6702,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);
|
||||
}
|
||||
|
||||
@@ -6902,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 {
|
||||
|
||||
Reference in New Issue
Block a user