mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
feat(desktop): redesign skills market
Refresh the skills market catalog and detail surfaces with denser workbench layouts, clearer action hierarchy, and updated status treatments. Tested: bun run check:desktop Tested: real browser smoke for list, detail, filters, install confirmation, and light/dark themes Confidence: high Scope-risk: narrow
This commit is contained in:
parent
25f19fad69
commit
203eb6b00d
@ -1,3 +1,4 @@
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Dropdown } from '../shared/Dropdown'
|
||||
import { useMarketStore, type MarketFilters } from '../../stores/marketStore'
|
||||
@ -9,19 +10,19 @@ import type {
|
||||
|
||||
function FilterTrigger({ label, value, active }: { label: string; value: string; active: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex min-h-9 items-center gap-1.5 rounded-xl border px-3 text-xs transition-colors ${
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
className={`inline-flex min-h-10 items-center gap-1.5 rounded-lg border px-3 text-xs transition-colors focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] active:scale-[0.98] ${
|
||||
active
|
||||
? 'border-[var(--color-brand)] bg-[var(--color-surface)] text-[var(--color-brand)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-focus)]'
|
||||
? 'border-[var(--color-brand)]/35 bg-[var(--color-primary-fixed)] text-[var(--color-brand)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="text-[var(--color-text-tertiary)]">{label}</span>
|
||||
<span className={active ? 'text-[var(--color-brand)]/75' : 'text-[var(--color-text-tertiary)]'}>{label}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden>
|
||||
expand_more
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,19 +1,20 @@
|
||||
import { CheckCircle2, CircleSlash2, Download, type LucideIcon } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { InstallState } from '../../types/market'
|
||||
|
||||
const STYLES: Record<InstallState, { icon: string; className: string }> = {
|
||||
const STYLES: Record<InstallState, { icon: LucideIcon; className: string }> = {
|
||||
installed: {
|
||||
icon: 'check_circle',
|
||||
className: 'bg-[var(--color-success-container)] text-[var(--color-success)]',
|
||||
icon: CheckCircle2,
|
||||
className: 'border-[var(--color-success)]/20 bg-[var(--color-success-container)] text-[var(--color-success)]',
|
||||
},
|
||||
installable: {
|
||||
icon: 'download',
|
||||
icon: Download,
|
||||
// brand-on-neutral keeps AA contrast in all three themes (brand-on-primary-fixed is 1.3:1 in dark).
|
||||
className: 'bg-[var(--color-surface-container)] text-[var(--color-brand)]',
|
||||
className: 'border-[var(--color-brand)]/20 bg-[var(--color-surface-container-low)] text-[var(--color-brand)]',
|
||||
},
|
||||
'not-installable': {
|
||||
icon: 'block',
|
||||
className: 'bg-[var(--color-error-container)] text-[var(--color-error)]',
|
||||
icon: CircleSlash2,
|
||||
className: 'border-[var(--color-error)]/20 bg-[var(--color-error-container)] text-[var(--color-error)]',
|
||||
},
|
||||
}
|
||||
|
||||
@ -26,14 +27,13 @@ const LABEL_KEYS: Record<InstallState, 'market.install.state.installed' | 'marke
|
||||
export function InstallStateBadge({ state, className = '' }: { state: InstallState; className?: string }) {
|
||||
const t = useTranslation()
|
||||
const style = STYLES[state]
|
||||
const Icon = style.icon
|
||||
return (
|
||||
<span
|
||||
data-testid={`install-badge-${state}`}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${style.className} ${className}`}
|
||||
className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-medium ${style.className} ${className}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]" aria-hidden>
|
||||
{style.icon}
|
||||
</span>
|
||||
<Icon className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
{t(LABEL_KEYS[state])}
|
||||
</span>
|
||||
)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { ShieldAlert, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
const STORAGE_KEY = 'cc-haha-market-disclaimer-dismissed'
|
||||
@ -26,12 +27,10 @@ export function MarketDisclaimer() {
|
||||
<div
|
||||
role="note"
|
||||
data-testid="market-disclaimer"
|
||||
className="flex items-start gap-3 rounded-xl border border-[var(--color-warning)]/35 bg-[var(--color-warning-container)] px-4 py-3"
|
||||
className="flex items-start gap-3 rounded-lg border border-[var(--color-warning)]/20 border-l-2 bg-[var(--color-surface-container-low)] px-3.5 py-2.5"
|
||||
>
|
||||
<span className="material-symbols-outlined mt-0.5 flex-shrink-0 text-[18px] text-[var(--color-warning)]" aria-hidden>
|
||||
gpp_maybe
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 text-xs leading-5 text-[var(--color-text-secondary)]">
|
||||
<ShieldAlert className="mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--color-warning)]" strokeWidth={2} aria-hidden="true" />
|
||||
<p className="min-w-0 flex-1 text-[11px] leading-[18px] text-[var(--color-text-secondary)] sm:text-xs sm:leading-5">
|
||||
<span className="font-semibold text-[var(--color-text-primary)]">{t('market.disclaimer.title')}</span>{' '}
|
||||
{t('market.disclaimer.body')}
|
||||
</p>
|
||||
@ -46,11 +45,9 @@ export function MarketDisclaimer() {
|
||||
// Persisting is best-effort; the banner stays dismissed for this session.
|
||||
}
|
||||
}}
|
||||
className="inline-flex h-7 w-7 flex-shrink-0 cursor-pointer items-center justify-center rounded-full text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-warning)]/10 hover:text-[var(--color-text-primary)]"
|
||||
className="-mr-1 inline-flex h-8 w-8 flex-shrink-0 cursor-pointer items-center justify-center rounded-md text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] active:scale-[0.98]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden>
|
||||
close
|
||||
</span>
|
||||
<X className="h-4 w-4" strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
68
desktop/src/components/market/MarketHome.test.tsx
Normal file
68
desktop/src/components/market/MarketHome.test.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useMarketStore } from '../../stores/marketStore'
|
||||
import type { NormalizedSkill } from '../../types/market'
|
||||
import { MarketHome } from './MarketHome'
|
||||
|
||||
function makeSkill(overrides: Partial<NormalizedSkill> = {}): NormalizedSkill {
|
||||
return {
|
||||
id: 'clawhub:demo',
|
||||
source: 'clawhub',
|
||||
slug: 'demo',
|
||||
name: 'Demo Skill',
|
||||
summary: 'A focused demo skill',
|
||||
author: { handle: 'alice', displayName: 'Alice' },
|
||||
stats: { downloads: 1_240, stars: 18 },
|
||||
tags: ['workflow'],
|
||||
version: '1.0.0',
|
||||
securityStatus: 'benign',
|
||||
installState: 'installable',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useMarketStore.setState({
|
||||
items: [makeSkill()],
|
||||
nextCursor: null,
|
||||
sources: {
|
||||
clawhub: { status: 'ok' },
|
||||
skillhub: { status: 'cached', fetchedAt: 1_700_000_000_000 },
|
||||
},
|
||||
query: '',
|
||||
filters: { source: 'all', security: 'all', installed: 'all' },
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
error: null,
|
||||
installingIds: new Set(),
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarketHome', () => {
|
||||
it('renders the compact catalog header, command bar, sources and semantic cards', () => {
|
||||
render(<MarketHome onRequestInstall={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Skills Market' })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('market-search-input')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('market-filter-bar')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('market-source-status-clawhub')).toHaveTextContent('Online')
|
||||
expect(screen.getByTestId('market-source-status-skillhub')).toHaveTextContent('Cached')
|
||||
expect(screen.getByTestId('market-grid')).toContainElement(screen.getByRole('article'))
|
||||
expect(screen.getByRole('button', { name: 'Demo Skill' })).toBeInTheDocument()
|
||||
expect(screen.getByText('1 skills')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses a catalog-shaped skeleton while the first page is loading', () => {
|
||||
useMarketStore.setState({ items: [], isLoading: true })
|
||||
|
||||
render(<MarketHome onRequestInstall={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('market-loading')).toHaveAttribute('aria-label', 'Loading skills…')
|
||||
expect(screen.queryByTestId('market-grid')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,5 @@
|
||||
import { useEffect } from 'react'
|
||||
import { CloudOff, PackageSearch, RefreshCw, Search, Store, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useMarketStore } from '../../stores/marketStore'
|
||||
import { FilterBar } from './FilterBar'
|
||||
@ -35,73 +36,81 @@ export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string
|
||||
const hasQuery = query.trim().length > 0
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-6">
|
||||
<MarketDisclaimer />
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-5 py-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-[var(--color-surface-container-lowest)]">
|
||||
<header className="shrink-0 border-b border-[var(--color-border)]/70 bg-[var(--color-surface)]">
|
||||
<div className="mx-auto flex w-full max-w-[1400px] flex-wrap items-center justify-between gap-5 px-6 py-6 lg:px-8">
|
||||
<div className="flex min-w-0 items-center gap-3.5">
|
||||
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-brand)] shadow-[0_1px_2px_rgba(27,28,26,0.06)]">
|
||||
<Store className="h-5 w-5" strokeWidth={1.9} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 flex items-center gap-2.5">
|
||||
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">storefront</span>
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">{t('market.title')}</h2>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">{t('market.subtitle')}</p>
|
||||
<h1 className="text-[22px] font-semibold leading-7 tracking-[-0.025em] text-[var(--color-text-primary)]">
|
||||
{t('market.title')}
|
||||
</h1>
|
||||
<p className="mt-0.5 max-w-2xl text-[13px] leading-5 text-[var(--color-text-secondary)]">
|
||||
{t('market.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<SourceStatusBar sources={sources} />
|
||||
</div>
|
||||
<SourceStatusBar sources={sources} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<div className="flex min-h-10 min-w-[260px] flex-1 items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 transition-colors focus-within:border-[var(--color-border-focus)] focus-within:ring-2 focus-within:ring-[var(--color-brand)]/20">
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">search</span>
|
||||
<div className="mx-auto flex w-full max-w-[1400px] flex-col gap-4 px-6 py-5 lg:px-8">
|
||||
<MarketDisclaimer />
|
||||
|
||||
<section className="sticky top-0 z-20 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-glass)] p-2.5 shadow-[0_8px_24px_rgba(27,28,26,0.06)] backdrop-blur-xl">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<div className="flex min-h-10 min-w-[260px] flex-1 items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 transition-colors focus-within:border-[var(--color-border-focus)] focus-within:shadow-[var(--shadow-focus-ring)]">
|
||||
<Search className="h-4 w-4 flex-shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={2} aria-hidden="true" />
|
||||
<input
|
||||
data-testid="market-search-input"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('market.searchPlaceholder')}
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
|
||||
aria-label={t('market.searchPlaceholder')}
|
||||
className="min-w-0 flex-1 bg-transparent text-[13px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('market.clearSearch')}
|
||||
onClick={() => setQuery('')}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
<X className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<FilterBar />
|
||||
</div>
|
||||
|
||||
{!isLoading && items.length > 0 && (
|
||||
<p className="mt-3 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('market.resultCount', { count: String(items.length) })}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center gap-3 py-16" data-testid="market-loading">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[var(--color-brand)] border-t-transparent" />
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">{t('market.loading')}</p>
|
||||
{!isLoading && items.length > 0 && (
|
||||
<div className="flex items-center gap-3 px-0.5">
|
||||
<p className="flex-shrink-0 text-[11px] font-medium tabular-nums text-[var(--color-text-tertiary)]">
|
||||
{t('market.resultCount', { count: String(items.length) })}
|
||||
</p>
|
||||
<div className="h-px flex-1 bg-[var(--color-border)]/60" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <MarketGridSkeleton label={t('market.loading')} />}
|
||||
|
||||
{!isLoading && error && (
|
||||
<div
|
||||
data-testid="market-error"
|
||||
className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-[var(--color-error)]/40 bg-[var(--color-error-container)]/30 px-6 py-12 text-center"
|
||||
className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-[var(--color-error)]/35 bg-[var(--color-error-container)]/25 px-6 py-14 text-center"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[36px] text-[var(--color-error)]">cloud_off</span>
|
||||
<CloudOff className="h-8 w-8 text-[var(--color-error)]" strokeWidth={1.7} aria-hidden="true" />
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)]">{t('market.error.list')}</p>
|
||||
<p className="max-w-md break-words text-xs text-[var(--color-text-tertiary)]">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fetchList({ reset: true })}
|
||||
className="mt-1 inline-flex min-h-9 items-center gap-1.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 text-sm text-[var(--color-text-primary)] transition-colors hover:border-[var(--color-border-focus)]"
|
||||
className="mt-1 inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-4 text-sm text-[var(--color-text-primary)] transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] active:scale-[0.98]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">refresh</span>
|
||||
<RefreshCw className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
{t('market.retry')}
|
||||
</button>
|
||||
</div>
|
||||
@ -110,11 +119,13 @@ export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string
|
||||
{!isLoading && !error && items.length === 0 && (
|
||||
<div
|
||||
data-testid="market-empty"
|
||||
className="rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-6 py-16 text-center"
|
||||
className="rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-6 py-16 text-center"
|
||||
>
|
||||
<span className="material-symbols-outlined mb-2 block text-[40px] text-[var(--color-text-tertiary)]">
|
||||
{hasQuery || hasActiveFilters ? 'search_off' : 'storefront'}
|
||||
</span>
|
||||
{hasQuery || hasActiveFilters ? (
|
||||
<PackageSearch className="mx-auto mb-3 h-9 w-9 text-[var(--color-text-tertiary)]" strokeWidth={1.6} aria-hidden="true" />
|
||||
) : (
|
||||
<Store className="mx-auto mb-3 h-9 w-9 text-[var(--color-text-tertiary)]" strokeWidth={1.6} aria-hidden="true" />
|
||||
)}
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">
|
||||
{hasQuery || hasActiveFilters ? t('market.emptySearch') : t('market.empty')}
|
||||
</p>
|
||||
@ -126,7 +137,7 @@ export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string
|
||||
|
||||
{!isLoading && items.length > 0 && (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3" data-testid="market-grid">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3" data-testid="market-grid">
|
||||
{items.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
@ -139,13 +150,13 @@ export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string
|
||||
</div>
|
||||
|
||||
{nextCursor && (
|
||||
<div className="flex justify-center pb-4">
|
||||
<div className="flex justify-center py-2 pb-5">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="market-load-more"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => void loadMore()}
|
||||
className="inline-flex min-h-10 items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 text-sm text-[var(--color-text-primary)] transition-colors hover:border-[var(--color-border-focus)] disabled:opacity-60"
|
||||
className="inline-flex min-h-10 items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-5 text-sm text-[var(--color-text-primary)] transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] active:scale-[0.98] disabled:opacity-60"
|
||||
>
|
||||
{isLoadingMore && (
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border border-current border-t-transparent" aria-hidden />
|
||||
@ -160,3 +171,32 @@ export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MarketGridSkeleton({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3" data-testid="market-loading" aria-label={label}>
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="min-h-[212px] animate-pulse rounded-xl border border-[var(--color-border)]/60 bg-[var(--color-surface-container-low)] p-4"
|
||||
>
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div className="h-[46px] w-[46px] rounded-[14px] bg-[var(--color-surface-container-high)]" />
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<div className="h-3.5 w-2/3 rounded bg-[var(--color-surface-container-high)]" />
|
||||
<div className="mt-2 h-2.5 w-1/3 rounded bg-[var(--color-surface-container)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2.5 w-full rounded bg-[var(--color-surface-container-high)]" />
|
||||
<div className="mt-2 h-2.5 w-4/5 rounded bg-[var(--color-surface-container)]" />
|
||||
<div className="mt-4 h-2.5 w-1/2 rounded bg-[var(--color-surface-container)]" />
|
||||
<div className="mt-5 h-px bg-[var(--color-border)]/50" />
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="h-6 w-20 rounded-md bg-[var(--color-surface-container-high)]" />
|
||||
<div className="h-6 w-24 rounded bg-[var(--color-surface-container)]" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { ArrowLeft, CircleAlert, Download, KeyRound, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useMarketStore } from '../../stores/marketStore'
|
||||
import { SkillDetailView, type SkillDetailMetaItem } from './SkillDetailView'
|
||||
@ -66,7 +67,7 @@ export function MarketSkillDetail({
|
||||
if (detail.requiresApiKey) {
|
||||
items.push({
|
||||
label: t('market.detail.requiresApiKey'),
|
||||
value: <span className="material-symbols-outlined text-[16px] text-[var(--color-warning)]">key</span>,
|
||||
value: <KeyRound className="ml-auto h-4 w-4 text-[var(--color-warning)]" strokeWidth={2} aria-hidden="true" />,
|
||||
})
|
||||
}
|
||||
return items
|
||||
@ -76,32 +77,58 @@ export function MarketSkillDetail({
|
||||
|
||||
if (isDetailLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-20" data-testid="market-detail-loading">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[var(--color-brand)] border-t-transparent" />
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">{t('market.loading')}</p>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-[var(--color-surface-container-lowest)]" data-testid="market-detail-loading">
|
||||
<div className="mx-auto w-full max-w-[1320px] px-6 py-6 lg:px-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={backToList}
|
||||
className="inline-flex min-h-8 items-center gap-1.5 rounded-md pr-2 text-sm text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" strokeWidth={2} aria-hidden="true" />
|
||||
{t('market.detail.back')}
|
||||
</button>
|
||||
<div className="mt-5 animate-pulse">
|
||||
<div className="flex items-start gap-5 border-b border-[var(--color-border)]/70 pb-6">
|
||||
<div className="h-16 w-16 flex-shrink-0 rounded-[14px] bg-[var(--color-surface-container-high)]" />
|
||||
<div className="min-w-0 flex-1 pt-1">
|
||||
<div className="h-2.5 w-24 rounded bg-[var(--color-surface-container)]" />
|
||||
<div className="mt-3 h-6 w-64 max-w-full rounded bg-[var(--color-surface-container-high)]" />
|
||||
<div className="mt-4 h-3 w-[min(100%,36rem)] rounded bg-[var(--color-surface-container)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<div>
|
||||
<div className="h-10 w-52 rounded bg-[var(--color-surface-container)]" />
|
||||
<div className="mt-5 h-72 rounded-xl border border-[var(--color-border)]/60 bg-[var(--color-surface-container-low)]" />
|
||||
</div>
|
||||
<div className="order-first h-72 rounded-xl border border-[var(--color-border)]/60 bg-[var(--color-surface-container-low)] lg:order-none" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="sr-only">{t('market.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (detailError || !detail) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-20 text-center" data-testid="market-detail-error">
|
||||
<span className="material-symbols-outlined text-[36px] text-[var(--color-error)]">error</span>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-20 text-center" data-testid="market-detail-error">
|
||||
<CircleAlert className="h-9 w-9 text-[var(--color-error)]" strokeWidth={1.7} aria-hidden="true" />
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)]">{t('market.detail.loadError')}</p>
|
||||
{detailError && <p className="max-w-md break-words text-xs text-[var(--color-text-tertiary)]">{detailError}</p>}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshDetail(selectedId)}
|
||||
className="inline-flex min-h-9 items-center gap-1.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 text-sm text-[var(--color-text-primary)] hover:border-[var(--color-border-focus)]"
|
||||
className="inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-4 text-sm text-[var(--color-text-primary)] transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] active:scale-[0.98]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">refresh</span>
|
||||
<RefreshCw className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
{t('market.retry')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={backToList}
|
||||
className="inline-flex min-h-9 items-center gap-1.5 rounded-xl px-4 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
|
||||
className="inline-flex min-h-9 items-center gap-1.5 rounded-lg px-4 text-sm text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] active:scale-[0.98]"
|
||||
>
|
||||
{t('market.detail.back')}
|
||||
</button>
|
||||
@ -125,12 +152,12 @@ export function MarketSkillDetail({
|
||||
data-testid="market-install-button"
|
||||
disabled={installing}
|
||||
onClick={() => onRequestInstall(detail.id)}
|
||||
className="inline-flex min-h-10 items-center gap-1.5 rounded-xl bg-[var(--color-brand)] px-5 text-sm font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
className="inline-flex min-h-10 items-center gap-2 rounded-lg bg-[image:var(--gradient-btn-primary)] px-5 text-sm font-semibold text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-[filter,transform] hover:brightness-105 focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] active:translate-y-px disabled:opacity-50"
|
||||
>
|
||||
{installing ? (
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border border-current border-t-transparent" aria-hidden />
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-[18px]" aria-hidden>download</span>
|
||||
<Download className="h-4 w-4" strokeWidth={2} aria-hidden="true" />
|
||||
)}
|
||||
{installing ? t('market.install.installing') : t('market.install.action')}
|
||||
</button>
|
||||
@ -141,12 +168,12 @@ export function MarketSkillDetail({
|
||||
data-testid="market-uninstall-button"
|
||||
disabled={installing}
|
||||
onClick={() => onRequestUninstall(detail.id)}
|
||||
className="inline-flex min-h-10 items-center gap-1.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 text-sm text-[var(--color-error)] transition-colors hover:border-[var(--color-error)]/50 disabled:opacity-50"
|
||||
className="inline-flex min-h-10 items-center gap-2 rounded-lg border border-[var(--color-error)]/25 bg-[var(--color-surface)] px-5 text-sm font-medium text-[var(--color-error)] transition-colors hover:border-[var(--color-error)]/50 hover:bg-[var(--color-error-container)]/35 focus-visible:outline-none focus-visible:shadow-[var(--shadow-error-ring)] active:scale-[0.98] disabled:opacity-50"
|
||||
>
|
||||
{installing ? (
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border border-current border-t-transparent" aria-hidden />
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-[18px]" aria-hidden>delete</span>
|
||||
<Trash2 className="h-4 w-4" strokeWidth={2} aria-hidden="true" />
|
||||
)}
|
||||
{installing ? t('market.uninstall.uninstalling') : t('market.uninstall.action')}
|
||||
</button>
|
||||
@ -164,9 +191,9 @@ export function MarketSkillDetail({
|
||||
{installError && installError.id === detail.id && (
|
||||
<div
|
||||
data-testid="market-install-error"
|
||||
className="mt-4 flex items-start gap-2 rounded-xl border border-[var(--color-error)]/30 bg-[var(--color-error-container)]/40 px-3.5 py-2.5 text-sm text-[var(--color-text-primary)]"
|
||||
className="mt-4 flex items-start gap-2 rounded-lg border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/35 px-3.5 py-2.5 text-sm text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined mt-0.5 text-[18px] text-[var(--color-error)]" aria-hidden>error</span>
|
||||
<CircleAlert className="mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--color-error)]" strokeWidth={2} aria-hidden="true" />
|
||||
<span className="break-words">
|
||||
{installError.kind === 'generic'
|
||||
? t('market.installError.generic', { message: installError.message })
|
||||
|
||||
@ -1,38 +1,38 @@
|
||||
import { BadgeCheck, ShieldAlert, ShieldCheck, ShieldQuestion, type LucideIcon } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { SecurityStatus } from '../../types/market'
|
||||
|
||||
const STYLES: Record<SecurityStatus, { icon: string; className: string }> = {
|
||||
const STYLES: Record<SecurityStatus, { icon: LucideIcon; className: string }> = {
|
||||
verified: {
|
||||
icon: 'verified',
|
||||
className: 'bg-[var(--color-success-container)] text-[var(--color-success)]',
|
||||
icon: BadgeCheck,
|
||||
className: 'border-[var(--color-success)]/20 bg-[var(--color-success-container)] text-[var(--color-success)]',
|
||||
},
|
||||
benign: {
|
||||
icon: 'gpp_good',
|
||||
className: 'bg-[var(--color-success-container)] text-[var(--color-success)]',
|
||||
icon: ShieldCheck,
|
||||
className: 'border-[var(--color-success)]/20 bg-[var(--color-success-container)] text-[var(--color-success)]',
|
||||
},
|
||||
unknown: {
|
||||
icon: 'shield_question',
|
||||
icon: ShieldQuestion,
|
||||
// text-secondary: tertiary lands at ~3.3-3.9:1 on this container across themes — below AA for 10px text.
|
||||
className: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-secondary)]',
|
||||
className: 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)]',
|
||||
},
|
||||
flagged: {
|
||||
icon: 'gpp_maybe',
|
||||
className: 'bg-[var(--color-error-container)] text-[var(--color-error)]',
|
||||
icon: ShieldAlert,
|
||||
className: 'border-[var(--color-error)]/20 bg-[var(--color-error-container)] text-[var(--color-error)]',
|
||||
},
|
||||
}
|
||||
|
||||
export function SecurityBadge({ status, className = '' }: { status: SecurityStatus; className?: string }) {
|
||||
const t = useTranslation()
|
||||
const style = STYLES[status]
|
||||
const Icon = style.icon
|
||||
return (
|
||||
<span
|
||||
data-testid={`security-badge-${status}`}
|
||||
title={t(`market.securityHint.${status}`)}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${style.className} ${className}`}
|
||||
className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-medium ${style.className} ${className}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]" aria-hidden>
|
||||
{style.icon}
|
||||
</span>
|
||||
<Icon className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
{t(`market.security.${status}`)}
|
||||
</span>
|
||||
)
|
||||
|
||||
@ -1,12 +1,21 @@
|
||||
import type { NormalizedSkill } from '../../types/market'
|
||||
|
||||
/** Deterministic hue from a string so every skill keeps a stable identity color. */
|
||||
function hashHue(input: string): number {
|
||||
const AVATAR_GRADIENTS = [
|
||||
['#9A5942', '#6F3827'],
|
||||
['#4F746D', '#31564F'],
|
||||
['#6C7651', '#465334'],
|
||||
['#6A687C', '#464558'],
|
||||
['#8A633D', '#634421'],
|
||||
['#647183', '#404C5C'],
|
||||
] as const
|
||||
|
||||
/** Deterministic palette index so every skill keeps a stable, restrained identity color. */
|
||||
function hashIndex(input: string): number {
|
||||
let hash = 0
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash = (hash * 31 + input.charCodeAt(i)) | 0
|
||||
}
|
||||
return Math.abs(hash) % 360
|
||||
return Math.abs(hash) % AVATAR_GRADIENTS.length
|
||||
}
|
||||
|
||||
/** First visible character of the name, uppercased (handles CJK and multi-byte chars). */
|
||||
@ -16,9 +25,9 @@ function initialOf(name: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill icon with a deterministic letter-avatar fallback.
|
||||
* Upstream sources rarely provide `iconUrl`, so the fallback carries the
|
||||
* visual identity: same skill name → same color + initial, every render.
|
||||
* Skill icon with a deterministic letter-avatar fallback. The palette stays
|
||||
* deliberately muted so a catalog of community skills still reads as one
|
||||
* product rather than a wall of unrelated app icons.
|
||||
*/
|
||||
export function SkillAvatar({
|
||||
skill,
|
||||
@ -36,11 +45,11 @@ export function SkillAvatar({
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{ width: size, height: size }}
|
||||
className={`flex-shrink-0 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container)] object-cover ${className}`}
|
||||
className={`flex-shrink-0 rounded-[14px] border border-[var(--color-border)] bg-[var(--color-surface-container)] object-cover shadow-[0_1px_2px_rgba(27,28,26,0.08)] ${className}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const hue = hashHue(skill.name)
|
||||
const [from, to] = AVATAR_GRADIENTS[hashIndex(skill.name)]!
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
@ -49,9 +58,10 @@ export function SkillAvatar({
|
||||
width: size,
|
||||
height: size,
|
||||
fontSize: Math.round(size * 0.45),
|
||||
background: `linear-gradient(135deg, hsl(${hue} 68% 52%), hsl(${(hue + 28) % 360} 64% 38%))`,
|
||||
background: `linear-gradient(145deg, ${from}, ${to})`,
|
||||
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.18), 0 3px 10px rgba(27,28,26,0.12)',
|
||||
}}
|
||||
className={`inline-flex flex-shrink-0 select-none items-center justify-center rounded-xl font-semibold text-white ${className}`}
|
||||
className={`inline-flex flex-shrink-0 select-none items-center justify-center rounded-[14px] font-semibold tracking-[-0.04em] text-white ${className}`}
|
||||
>
|
||||
{initialOf(skill.name)}
|
||||
</span>
|
||||
|
||||
@ -65,10 +65,22 @@ describe('SkillCard', () => {
|
||||
expect(onInstall).toHaveBeenCalledWith('clawhub:demo')
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByText('Demo Skill'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Demo Skill' }))
|
||||
expect(onOpen).toHaveBeenCalledWith('clawhub:demo')
|
||||
})
|
||||
|
||||
it('uses separate semantic actions for opening and installing', () => {
|
||||
render(<SkillCard skill={makeSkill()} onOpen={vi.fn()} onInstall={vi.fn()} />)
|
||||
|
||||
const card = screen.getByRole('article')
|
||||
const openButton = screen.getByRole('button', { name: 'Demo Skill' })
|
||||
const installButton = screen.getByRole('button', { name: 'Install' })
|
||||
|
||||
expect(card).toContainElement(openButton)
|
||||
expect(card).toContainElement(installButton)
|
||||
expect(openButton).not.toContainElement(installButton)
|
||||
})
|
||||
|
||||
it('disables the quick-install button while installing', () => {
|
||||
render(<SkillCard skill={makeSkill()} onOpen={vi.fn()} onInstall={vi.fn()} installing />)
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { ArrowUpRight, Download, Star } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { NormalizedSkill } from '../../types/market'
|
||||
import { InstallStateBadge } from './InstallStateBadge'
|
||||
@ -28,54 +29,64 @@ export function SkillCard({
|
||||
const showInstallButton = Boolean(onInstall) && skill.installState === 'installable'
|
||||
|
||||
return (
|
||||
<div
|
||||
<article
|
||||
data-testid={`market-skill-card-${skill.id}`}
|
||||
className="group relative flex min-w-0 cursor-pointer flex-col rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--color-border-focus)] hover:shadow-[var(--shadow-dropdown)]"
|
||||
style={{ contentVisibility: 'auto', containIntrinsicSize: '196px' }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpen(skill.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onOpen(skill.id)
|
||||
}
|
||||
}}
|
||||
className="group relative isolate flex min-h-[212px] min-w-0 flex-col overflow-hidden rounded-xl border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] p-4 transition-[background-color,border-color,box-shadow] duration-200 hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface)] hover:shadow-[var(--shadow-dropdown)]"
|
||||
style={{ contentVisibility: 'auto', containIntrinsicSize: '212px' }}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<SkillAvatar skill={skill} size={40} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label={skill.name}
|
||||
onClick={() => onOpen(skill.id)}
|
||||
className="absolute inset-0 z-0 rounded-xl focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-5 top-0 z-10 h-px bg-gradient-to-r from-transparent via-[var(--color-brand)]/55 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
|
||||
|
||||
<div className="pointer-events-none relative z-10 flex items-start gap-3.5">
|
||||
<SkillAvatar skill={skill} size={46} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-[var(--color-text-primary)]">{skill.name}</span>
|
||||
<div className="flex items-start gap-2">
|
||||
<h3 className="min-w-0 flex-1 truncate text-[15px] font-semibold leading-5 tracking-[-0.01em] text-[var(--color-text-primary)]">
|
||||
{skill.name}
|
||||
</h3>
|
||||
{skill.version && (
|
||||
<span className="flex-shrink-0 rounded-full bg-[var(--color-surface-container-high)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
|
||||
<span className="flex-shrink-0 font-mono text-[10px] leading-5 text-[var(--color-text-tertiary)]">
|
||||
v{skill.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<span className="flex-shrink-0 rounded-md bg-[var(--color-surface-container)] px-1.5 py-px font-medium text-[var(--color-text-secondary)]">
|
||||
<span className="flex-shrink-0 font-semibold uppercase tracking-[0.08em] text-[var(--color-text-secondary)]">
|
||||
{t(`market.source.${skill.source}`)}
|
||||
</span>
|
||||
{skill.author.handle && (
|
||||
<span className="truncate">{t('market.card.by', { author: skill.author.displayName || skill.author.handle })}</span>
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="truncate">{t('market.card.by', { author: skill.author.displayName || skill.author.handle })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowUpRight
|
||||
className="mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--color-text-tertiary)] opacity-0 transition-[opacity,transform] duration-200 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 group-hover:opacity-100"
|
||||
strokeWidth={1.8}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-2.5 line-clamp-2 min-h-[2.5rem] text-xs leading-5 text-[var(--color-text-secondary)] break-words">
|
||||
<p className="pointer-events-none relative z-10 mt-3 line-clamp-2 min-h-[2.75rem] text-[12px] leading-[1.375rem] text-[var(--color-text-secondary)] break-words">
|
||||
{skill.summary || t('market.detail.noDescription')}
|
||||
</p>
|
||||
|
||||
{skill.tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
<div className="pointer-events-none relative z-10 mt-2.5 flex min-h-4 flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
{skill.tags.slice(0, MAX_VISIBLE_TAGS).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-[var(--color-surface-container)] px-2 py-0.5 text-[10px] text-[var(--color-text-secondary)]"
|
||||
className="text-[10px] text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{tag}
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{extraTags > 0 && (
|
||||
@ -86,7 +97,7 @@ export function SkillCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex flex-wrap items-center justify-between gap-x-2 gap-y-1.5 border-t border-[var(--color-border)]/60 pt-2.5">
|
||||
<footer className="pointer-events-none relative z-10 mt-auto flex flex-wrap items-center justify-between gap-x-2 gap-y-2 border-t border-[var(--color-border)]/60 pt-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<SecurityBadge status={skill.securityStatus} />
|
||||
{/* The quick-install button already communicates "installable" — skip the badge when the button renders. */}
|
||||
@ -94,14 +105,14 @@ export function SkillCard({
|
||||
<InstallStateBadge state={skill.installState} />
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex flex-shrink-0 items-center gap-2 text-[11px] tabular-nums text-[var(--color-text-tertiary)]">
|
||||
<span className="inline-flex items-center gap-0.5" title={t('market.detail.downloads')}>
|
||||
<span className="material-symbols-outlined text-[13px]" aria-hidden>download</span>
|
||||
<div className="ml-auto flex flex-shrink-0 items-center gap-2.5 text-[11px] tabular-nums text-[var(--color-text-tertiary)]">
|
||||
<span className="inline-flex items-center gap-1" title={t('market.detail.downloads')}>
|
||||
<Download className="h-3.5 w-3.5" strokeWidth={1.8} aria-hidden="true" />
|
||||
{formatCount(skill.stats.downloads)}
|
||||
</span>
|
||||
{typeof skill.stats.stars === 'number' && skill.stats.stars > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5" title={t('market.detail.stars')}>
|
||||
<span className="material-symbols-outlined text-[13px]" aria-hidden>star</span>
|
||||
<span className="inline-flex items-center gap-1" title={t('market.detail.stars')}>
|
||||
<Star className="h-3.5 w-3.5" strokeWidth={1.8} aria-hidden="true" />
|
||||
{formatCount(skill.stats.stars)}
|
||||
</span>
|
||||
)}
|
||||
@ -109,22 +120,19 @@ export function SkillCard({
|
||||
<button
|
||||
type="button"
|
||||
disabled={installing}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onInstall?.(skill.id)
|
||||
}}
|
||||
className="inline-flex min-h-7 cursor-pointer items-center gap-1 rounded-lg bg-[var(--color-brand)] px-2.5 text-[11px] font-medium text-[var(--color-on-primary)] transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
onClick={() => onInstall?.(skill.id)}
|
||||
className="pointer-events-auto relative z-20 inline-flex min-h-8 cursor-pointer items-center gap-1.5 rounded-md border border-[var(--color-brand)]/25 bg-[var(--color-surface)] px-2.5 text-[11px] font-semibold text-[var(--color-brand)] transition-colors hover:border-[var(--color-brand)]/45 hover:bg-[var(--color-primary-fixed)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] active:scale-[0.98] disabled:opacity-50"
|
||||
>
|
||||
{installing ? (
|
||||
<span className="h-3 w-3 animate-spin rounded-full border border-current border-t-transparent" aria-hidden />
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-[13px]" aria-hidden>download</span>
|
||||
<Download className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
)}
|
||||
{installing ? t('market.install.installing') : t('market.install.action')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
vi.mock('../markdown/MarkdownRenderer', () => ({
|
||||
@ -113,6 +113,9 @@ describe('SkillDetailView', () => {
|
||||
it('renders custom actions in the decision area', () => {
|
||||
renderView({ actions: <button type="button">Install now</button> })
|
||||
|
||||
expect(screen.getByText('Install now')).toBeInTheDocument()
|
||||
const decisionSidebar = screen.getByTestId('skill-detail-sidebar')
|
||||
expect(within(decisionSidebar).getByText('Install now')).toBeInTheDocument()
|
||||
expect(within(decisionSidebar).getByText('Author')).toBeInTheDocument()
|
||||
expect(within(decisionSidebar).getByText('Alice')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { ArrowLeft, CircleSlash2, FileText, Folder } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type {
|
||||
InstallState,
|
||||
@ -10,6 +11,7 @@ import { InstallStateBadge } from './InstallStateBadge'
|
||||
import { SecurityBadge } from './SecurityBadge'
|
||||
import { FilePreview, type PreviewFile, type PreviewFileContent } from './FilePreview'
|
||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
import { SkillAvatar } from './SkillAvatar'
|
||||
|
||||
export type SkillDetailMetaItem = {
|
||||
label: string
|
||||
@ -48,79 +50,70 @@ export function SkillDetailView(props: SkillDetailViewProps) {
|
||||
const [tab, setTab] = useState<'overview' | 'files'>('overview')
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto" data-testid="skill-detail-view">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-5 px-6 py-6">
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-[var(--color-surface-container-lowest)]"
|
||||
data-testid="skill-detail-view"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[1320px] px-6 py-6 lg:px-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onBack}
|
||||
className="inline-flex w-fit items-center gap-1 text-sm text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
className="inline-flex min-h-8 w-fit items-center gap-1.5 rounded-md pr-2 text-sm text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] active:scale-[0.98]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">arrow_back</span>
|
||||
<ArrowLeft className="h-4 w-4" strokeWidth={2} aria-hidden="true" />
|
||||
{props.backLabel}
|
||||
</button>
|
||||
|
||||
{/* Install decision area */}
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-5 py-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3.5">
|
||||
{props.iconUrl ? (
|
||||
<img
|
||||
src={props.iconUrl}
|
||||
alt=""
|
||||
className="h-12 w-12 flex-shrink-0 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container)] object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]">
|
||||
<span className="material-symbols-outlined text-[24px]">auto_awesome</span>
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="break-all text-xl font-semibold text-[var(--color-text-primary)]">{props.name}</h2>
|
||||
{props.version && (
|
||||
<span className="rounded-full bg-[var(--color-surface-container-high)] px-2 py-0.5 text-[11px] font-medium text-[var(--color-text-tertiary)]">
|
||||
v{props.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
<span className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
|
||||
{props.sourceLabel}
|
||||
</span>
|
||||
{props.securityStatus && <SecurityBadge status={props.securityStatus} />}
|
||||
{props.installState && <InstallStateBadge state={props.installState} />}
|
||||
</div>
|
||||
{props.summary && (
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-[var(--color-text-secondary)] break-words">
|
||||
{props.summary}
|
||||
</p>
|
||||
<header className="mt-5 border-b border-[var(--color-border)]/70 pb-6">
|
||||
<div className="flex min-w-0 items-start gap-4 sm:gap-5">
|
||||
<SkillAvatar skill={{ name: props.name, iconUrl: props.iconUrl }} size={64} />
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]">
|
||||
<span>{props.sourceLabel}</span>
|
||||
{props.version && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="font-mono font-normal normal-case tracking-normal">v{props.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="mt-1.5 break-words text-2xl font-semibold leading-8 tracking-[-0.03em] text-[var(--color-text-primary)] sm:text-[28px]">
|
||||
{props.name}
|
||||
</h1>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{props.securityStatus && <SecurityBadge status={props.securityStatus} />}
|
||||
{props.installState && <InstallStateBadge state={props.installState} />}
|
||||
</div>
|
||||
{props.summary && (
|
||||
<p className="mt-3 max-w-3xl text-[13px] leading-6 text-[var(--color-text-secondary)] break-words sm:text-sm">
|
||||
{props.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{props.actions && <div className="flex flex-shrink-0 items-center gap-2">{props.actions}</div>}
|
||||
</div>
|
||||
|
||||
{props.installState === 'not-installable' && props.notInstallableReason && (
|
||||
<div
|
||||
data-testid="market-not-installable-reason"
|
||||
className="mt-4 flex items-start gap-2 rounded-xl border border-[var(--color-error)]/30 bg-[var(--color-error-container)]/40 px-3.5 py-2.5 text-sm text-[var(--color-text-primary)]"
|
||||
className="mt-5 flex items-start gap-2 rounded-lg border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/35 px-3.5 py-2.5 text-sm text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined mt-0.5 text-[18px] text-[var(--color-error)]" aria-hidden>
|
||||
block
|
||||
</span>
|
||||
<CircleSlash2 className="mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--color-error)]" strokeWidth={2} aria-hidden="true" />
|
||||
<span>{t(`market.reason.${props.notInstallableReason}`)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.securityReports && props.securityReports.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2" data-testid="market-security-reports">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-[var(--color-text-tertiary)]">
|
||||
<div
|
||||
className="mt-5 flex flex-wrap items-center gap-x-2.5 gap-y-2 border-t border-[var(--color-border)]/60 pt-4"
|
||||
data-testid="market-security-reports"
|
||||
>
|
||||
<span className="mr-1 text-[11px] font-semibold text-[var(--color-text-tertiary)]">
|
||||
{t('market.detail.securityReport')}
|
||||
</span>
|
||||
{props.securityReports.map((report) => (
|
||||
<span
|
||||
key={report.vendor}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)]"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--color-surface-container-low)] px-2.5 py-1.5 text-[11px] text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<span className="font-medium text-[var(--color-text-primary)]">{report.vendor}</span>
|
||||
{report.statusText}
|
||||
@ -141,61 +134,104 @@ export function SkillDetailView(props: SkillDetailViewProps) {
|
||||
)}
|
||||
|
||||
{props.banner}
|
||||
</section>
|
||||
</header>
|
||||
|
||||
{/* Meta grid */}
|
||||
{props.meta.length > 0 && (
|
||||
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{props.meta.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="min-w-0 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2.5"
|
||||
>
|
||||
<div className="text-[10px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">{item.label}</div>
|
||||
<div className="mt-1 truncate text-sm font-medium text-[var(--color-text-primary)]">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 border-b border-[var(--color-border)]">
|
||||
{(['overview', 'files'] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
data-testid={`skill-detail-tab-${key}`}
|
||||
onClick={() => setTab(key)}
|
||||
className={`relative -mb-px inline-flex min-h-10 items-center gap-1.5 border-b-2 px-3.5 text-sm transition-colors ${
|
||||
tab === key
|
||||
? 'border-[var(--color-brand)] font-medium text-[var(--color-text-primary)]'
|
||||
: 'border-transparent text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_280px] lg:items-start">
|
||||
<main className="min-w-0">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={props.name}
|
||||
className="flex items-center gap-1 border-b border-[var(--color-border)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden>
|
||||
{key === 'overview' ? 'article' : 'folder'}
|
||||
</span>
|
||||
{t(`market.detail.${key}`)}
|
||||
{key === 'files' && (
|
||||
<span className="rounded-full bg-[var(--color-surface-container-high)] px-1.5 text-[10px] text-[var(--color-text-tertiary)]">
|
||||
{props.files.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{(['overview', 'files'] as const).map((key) => {
|
||||
const active = tab === key
|
||||
const Icon = key === 'overview' ? FileText : Folder
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
id={`skill-detail-tab-${key}-trigger`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-controls={`skill-detail-${key}-panel`}
|
||||
data-testid={`skill-detail-tab-${key}`}
|
||||
onClick={() => setTab(key)}
|
||||
className={`relative -mb-px inline-flex min-h-10 items-center gap-1.5 border-b-2 px-3.5 text-sm transition-colors focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
|
||||
active
|
||||
? 'border-[var(--color-brand)] font-medium text-[var(--color-text-primary)]'
|
||||
: 'border-transparent text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" strokeWidth={1.9} aria-hidden="true" />
|
||||
{t(`market.detail.${key}`)}
|
||||
{key === 'files' && (
|
||||
<span className="rounded-md bg-[var(--color-surface-container-high)] px-1.5 py-0.5 text-[10px] leading-4 text-[var(--color-text-tertiary)]">
|
||||
{props.files.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tab === 'overview' && (
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-6 py-5" data-testid="skill-detail-overview">
|
||||
{props.description.trim() ? (
|
||||
<MarkdownRenderer content={props.description} variant="document" className="mx-auto max-w-[72ch]" />
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-[var(--color-text-tertiary)]">{t('market.detail.noDescription')}</p>
|
||||
{tab === 'overview' && (
|
||||
<section
|
||||
id="skill-detail-overview-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby="skill-detail-tab-overview-trigger"
|
||||
className="mt-5 rounded-xl border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-6 py-6 sm:px-8 sm:py-7"
|
||||
data-testid="skill-detail-overview"
|
||||
>
|
||||
{props.description.trim() ? (
|
||||
<MarkdownRenderer content={props.description} variant="document" className="mx-auto max-w-[72ch]" />
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-[var(--color-text-tertiary)]">{t('market.detail.noDescription')}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'files' && <FilePreview files={props.files} loadFile={props.loadFile} />}
|
||||
{tab === 'files' && (
|
||||
<section
|
||||
id="skill-detail-files-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby="skill-detail-tab-files-trigger"
|
||||
className="mt-5"
|
||||
>
|
||||
<FilePreview files={props.files} loadFile={props.loadFile} />
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside
|
||||
data-testid="skill-detail-sidebar"
|
||||
className="order-first min-w-0 lg:order-none lg:sticky lg:top-5"
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] shadow-[0_1px_2px_rgba(27,28,26,0.05)]">
|
||||
{props.actions && (
|
||||
<div
|
||||
className={`p-3 [&>button]:w-full [&>button]:justify-center ${props.meta.length > 0 ? 'border-b border-[var(--color-border)]' : ''}`}
|
||||
>
|
||||
{props.actions}
|
||||
</div>
|
||||
)}
|
||||
{props.meta.length > 0 && (
|
||||
<dl>
|
||||
{props.meta.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex min-w-0 items-start justify-between gap-4 border-b border-[var(--color-border)]/65 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<dt className="text-[11px] leading-5 text-[var(--color-text-tertiary)]">{item.label}</dt>
|
||||
<dd className="max-w-[62%] break-words text-right text-[12px] font-medium leading-5 text-[var(--color-text-primary)]">
|
||||
{item.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -27,7 +27,10 @@ export function SourceStatusBar({
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-2 ${className}`} data-testid="market-source-status">
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-1 rounded-lg bg-[var(--color-surface-container-low)] p-1 ${className}`}
|
||||
data-testid="market-source-status"
|
||||
>
|
||||
{MARKET_SOURCES.map((source) => {
|
||||
const info = sources[source]
|
||||
if (!info) return null
|
||||
@ -40,11 +43,11 @@ export function SourceStatusBar({
|
||||
key={source}
|
||||
data-testid={`market-source-status-${source}`}
|
||||
title={info.error || undefined}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)]"
|
||||
className="inline-flex min-h-7 items-center gap-1.5 rounded-md px-2 text-[11px] text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<span className={`h-2 w-2 rounded-full border border-[var(--color-border)] ${DOT_CLASSES[info.status]}`} aria-hidden />
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${DOT_CLASSES[info.status]}`} aria-hidden />
|
||||
<span className="font-medium text-[var(--color-text-primary)]">{t(`market.source.${source}`)}</span>
|
||||
<span>{statusLabel}</span>
|
||||
<span className="text-[var(--color-text-tertiary)]">{statusLabel}</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user