feat: refine skill market cards and drawer

This commit is contained in:
程序员阿江(Relakkes) 2026-07-04 22:08:16 +08:00
parent 697b2efdc9
commit 8405314761
7 changed files with 350 additions and 107 deletions

View File

@ -2027,6 +2027,9 @@ export const en = {
'skillCenter.marketplace.filter.apiKey': 'API key',
'skillCenter.marketplace.total': 'Total',
'skillCenter.marketplace.loadedCount': '{count} loaded',
'skillCenter.marketplace.status.loaded': '{count} loaded',
'skillCenter.marketplace.status.safe': '{safe}/{total} verified',
'skillCenter.marketplace.status.installed': '{count} installed',
'skillCenter.marketplace.totalHint': 'Current result set',
'skillCenter.marketplace.safe': 'Verified',
'skillCenter.marketplace.safeHint': 'Clean or trusted signals',
@ -2053,6 +2056,10 @@ export const en = {
'skillCenter.marketplace.sort.updated': 'Updated',
'skillCenter.marketplace.sort.trending': 'Trending',
'skillCenter.marketplace.installedBadge': 'Installed',
'skillCenter.marketplace.previewAvailable': 'Preview',
'skillCenter.marketplace.noPreviewBadge': 'No preview',
'skillCenter.marketplace.cardMeta.downloads': '{count} downloads',
'skillCenter.marketplace.cardMeta.stars': '{count} stars',
'skillCenter.marketplace.trust.clean': 'Clean',
'skillCenter.marketplace.trust.benign': 'Benign',
'skillCenter.marketplace.trust.signed': 'Signed',
@ -2074,6 +2081,12 @@ export const en = {
'skillCenter.marketplace.risk.requires-api-key': 'API key',
'skillCenter.marketplace.entryPreview': 'Entry preview',
'skillCenter.marketplace.filePreview': 'File preview',
'skillCenter.marketplace.drawer.summary': 'Summary',
'skillCenter.marketplace.drawer.decision': 'Install decision',
'skillCenter.marketplace.drawer.safety': 'Safety',
'skillCenter.marketplace.drawer.metadata': 'Metadata',
'skillCenter.marketplace.drawer.links': 'Links',
'skillCenter.marketplace.loadingCard': 'Loading marketplace skills',
'skillCenter.marketplace.previewTruncated': 'Truncated',
'skillCenter.marketplace.previewUnavailable': 'Preview unavailable',
'skillCenter.marketplace.previewUnavailable.skillhub': 'SkillHub does not expose a safe raw file preview endpoint yet.',

View File

@ -2029,6 +2029,9 @@ export const jp: Record<TranslationKey, string> = {
'skillCenter.marketplace.filter.apiKey': 'API キー',
'skillCenter.marketplace.total': '合計',
'skillCenter.marketplace.loadedCount': '{count} 件読み込み済み',
'skillCenter.marketplace.status.loaded': '{count} 件読み込み済み',
'skillCenter.marketplace.status.safe': '{safe}/{total} 検証済み',
'skillCenter.marketplace.status.installed': '{count} 件インストール済み',
'skillCenter.marketplace.totalHint': '現在の結果',
'skillCenter.marketplace.safe': '信頼済み',
'skillCenter.marketplace.safeHint': '安全または信頼シグナル',
@ -2055,6 +2058,10 @@ export const jp: Record<TranslationKey, string> = {
'skillCenter.marketplace.sort.updated': '更新日時',
'skillCenter.marketplace.sort.trending': '人気',
'skillCenter.marketplace.installedBadge': 'インストール済み',
'skillCenter.marketplace.previewAvailable': 'プレビュー',
'skillCenter.marketplace.noPreviewBadge': 'プレビューなし',
'skillCenter.marketplace.cardMeta.downloads': '{count} ダウンロード',
'skillCenter.marketplace.cardMeta.stars': '{count} スター',
'skillCenter.marketplace.trust.clean': '安全',
'skillCenter.marketplace.trust.benign': '低リスク',
'skillCenter.marketplace.trust.signed': '署名済み',
@ -2076,6 +2083,12 @@ export const jp: Record<TranslationKey, string> = {
'skillCenter.marketplace.risk.requires-api-key': 'API キー',
'skillCenter.marketplace.entryPreview': 'エントリプレビュー',
'skillCenter.marketplace.filePreview': 'ファイルプレビュー',
'skillCenter.marketplace.drawer.summary': '概要',
'skillCenter.marketplace.drawer.decision': 'インストール判断',
'skillCenter.marketplace.drawer.safety': '安全',
'skillCenter.marketplace.drawer.metadata': 'メタデータ',
'skillCenter.marketplace.drawer.links': 'リンク',
'skillCenter.marketplace.loadingCard': 'マーケットスキルを読み込み中',
'skillCenter.marketplace.previewTruncated': '切り詰め済み',
'skillCenter.marketplace.previewUnavailable': 'プレビュー不可',
'skillCenter.marketplace.previewUnavailable.skillhub': 'SkillHub はまだ安全な raw ファイルプレビュー API を提供していません。',

View File

@ -2029,6 +2029,9 @@ export const kr: Record<TranslationKey, string> = {
'skillCenter.marketplace.filter.apiKey': 'API 키',
'skillCenter.marketplace.total': '전체',
'skillCenter.marketplace.loadedCount': '{count}개 로드됨',
'skillCenter.marketplace.status.loaded': '{count}개 로드됨',
'skillCenter.marketplace.status.safe': '{safe}/{total} 검증됨',
'skillCenter.marketplace.status.installed': '{count}개 설치됨',
'skillCenter.marketplace.totalHint': '현재 결과',
'skillCenter.marketplace.safe': '신뢰됨',
'skillCenter.marketplace.safeHint': '안전 또는 신뢰 신호',
@ -2055,6 +2058,10 @@ export const kr: Record<TranslationKey, string> = {
'skillCenter.marketplace.sort.updated': '업데이트',
'skillCenter.marketplace.sort.trending': '인기',
'skillCenter.marketplace.installedBadge': '설치됨',
'skillCenter.marketplace.previewAvailable': '미리보기',
'skillCenter.marketplace.noPreviewBadge': '미리보기 없음',
'skillCenter.marketplace.cardMeta.downloads': '{count} 다운로드',
'skillCenter.marketplace.cardMeta.stars': '{count} 스타',
'skillCenter.marketplace.trust.clean': '안전',
'skillCenter.marketplace.trust.benign': '낮은 위험',
'skillCenter.marketplace.trust.signed': '서명됨',
@ -2076,6 +2083,12 @@ export const kr: Record<TranslationKey, string> = {
'skillCenter.marketplace.risk.requires-api-key': 'API 키',
'skillCenter.marketplace.entryPreview': '엔트리 미리보기',
'skillCenter.marketplace.filePreview': '파일 미리보기',
'skillCenter.marketplace.drawer.summary': '요약',
'skillCenter.marketplace.drawer.decision': '설치 판단',
'skillCenter.marketplace.drawer.safety': '안전',
'skillCenter.marketplace.drawer.metadata': '메타데이터',
'skillCenter.marketplace.drawer.links': '링크',
'skillCenter.marketplace.loadingCard': '마켓 스킬 로드 중',
'skillCenter.marketplace.previewTruncated': '잘림',
'skillCenter.marketplace.previewUnavailable': '미리보기 불가',
'skillCenter.marketplace.previewUnavailable.skillhub': 'SkillHub는 아직 안전한 원본 파일 미리보기 API를 제공하지 않습니다.',

View File

@ -2029,6 +2029,9 @@ export const zh: Record<TranslationKey, string> = {
'skillCenter.marketplace.filter.apiKey': '需 Key',
'skillCenter.marketplace.total': '總數',
'skillCenter.marketplace.loadedCount': '已載入 {count} 個',
'skillCenter.marketplace.status.loaded': '已載入 {count} 個',
'skillCenter.marketplace.status.safe': '{safe}/{total} 已驗證',
'skillCenter.marketplace.status.installed': '已安裝 {count} 個',
'skillCenter.marketplace.totalHint': '目前結果集',
'skillCenter.marketplace.safe': '可信',
'skillCenter.marketplace.safeHint': '安全或可信訊號',
@ -2055,6 +2058,10 @@ export const zh: Record<TranslationKey, string> = {
'skillCenter.marketplace.sort.updated': '最近更新',
'skillCenter.marketplace.sort.trending': '熱門',
'skillCenter.marketplace.installedBadge': '已安裝',
'skillCenter.marketplace.previewAvailable': '可預覽',
'skillCenter.marketplace.noPreviewBadge': '無預覽',
'skillCenter.marketplace.cardMeta.downloads': '{count} 次下載',
'skillCenter.marketplace.cardMeta.stars': '{count} 星標',
'skillCenter.marketplace.trust.clean': '安全',
'skillCenter.marketplace.trust.benign': '低風險',
'skillCenter.marketplace.trust.signed': '已簽名',
@ -2076,6 +2083,12 @@ export const zh: Record<TranslationKey, string> = {
'skillCenter.marketplace.risk.requires-api-key': 'API Key',
'skillCenter.marketplace.entryPreview': '入口預覽',
'skillCenter.marketplace.filePreview': '檔案預覽',
'skillCenter.marketplace.drawer.summary': '摘要',
'skillCenter.marketplace.drawer.decision': '安裝決策',
'skillCenter.marketplace.drawer.safety': '安全',
'skillCenter.marketplace.drawer.metadata': '中繼資料',
'skillCenter.marketplace.drawer.links': '連結',
'skillCenter.marketplace.loadingCard': '正在載入市場技能',
'skillCenter.marketplace.previewTruncated': '已截斷',
'skillCenter.marketplace.previewUnavailable': '暫時無法預覽',
'skillCenter.marketplace.previewUnavailable.skillhub': 'SkillHub 目前尚未提供安全的原始檔案預覽介面。',

View File

@ -2029,6 +2029,9 @@ export const zh: Record<TranslationKey, string> = {
'skillCenter.marketplace.filter.apiKey': '需 Key',
'skillCenter.marketplace.total': '总数',
'skillCenter.marketplace.loadedCount': '已加载 {count} 个',
'skillCenter.marketplace.status.loaded': '已加载 {count} 个',
'skillCenter.marketplace.status.safe': '{safe}/{total} 已验证',
'skillCenter.marketplace.status.installed': '已安装 {count} 个',
'skillCenter.marketplace.totalHint': '当前结果集',
'skillCenter.marketplace.safe': '可信',
'skillCenter.marketplace.safeHint': '安全或可信信号',
@ -2055,6 +2058,10 @@ export const zh: Record<TranslationKey, string> = {
'skillCenter.marketplace.sort.updated': '最近更新',
'skillCenter.marketplace.sort.trending': '热门',
'skillCenter.marketplace.installedBadge': '已安装',
'skillCenter.marketplace.previewAvailable': '可预览',
'skillCenter.marketplace.noPreviewBadge': '无预览',
'skillCenter.marketplace.cardMeta.downloads': '{count} 次下载',
'skillCenter.marketplace.cardMeta.stars': '{count} 星标',
'skillCenter.marketplace.trust.clean': '安全',
'skillCenter.marketplace.trust.benign': '低风险',
'skillCenter.marketplace.trust.signed': '已签名',
@ -2076,6 +2083,12 @@ export const zh: Record<TranslationKey, string> = {
'skillCenter.marketplace.risk.requires-api-key': 'API Key',
'skillCenter.marketplace.entryPreview': '入口预览',
'skillCenter.marketplace.filePreview': '文件预览',
'skillCenter.marketplace.drawer.summary': '摘要',
'skillCenter.marketplace.drawer.decision': '安装决策',
'skillCenter.marketplace.drawer.safety': '安全',
'skillCenter.marketplace.drawer.metadata': '元数据',
'skillCenter.marketplace.drawer.links': '链接',
'skillCenter.marketplace.loadingCard': '正在加载市场技能',
'skillCenter.marketplace.previewTruncated': '已截断',
'skillCenter.marketplace.previewUnavailable': '暂时无法预览',
'skillCenter.marketplace.previewUnavailable.skillhub': 'SkillHub 目前还没有提供安全的原始文件预览接口。',

View File

@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { skillMarketApi } from '../api/skillMarket'
@ -185,6 +185,93 @@ describe('SkillCenter', () => {
})
})
it('renders marketplace cards with trust, preview, install, API key, and popularity signals', async () => {
mockedSkillMarketApi.list.mockResolvedValue({
items: [
makeItem({
slug: 'skill-vetter',
displayName: 'Skill Vetter',
summary: 'Security-first skill vetting.',
downloads: 260960,
trustState: 'clean',
installed: false,
}),
makeItem({
slug: 'weather',
displayName: 'Weather',
summary: 'Fetch current weather through a command-line workflow.',
downloads: 18420,
trustState: 'clean',
installed: true,
requiresApiKey: false,
}),
makeItem({
slug: 'crm-sync',
displayName: 'CRM Sync',
summary: 'Connects to an external CRM API.',
downloads: 7420,
trustState: 'warning',
requiresApiKey: true,
}),
],
nextCursor: null,
source: 'clawhub',
sourceStatus: 'ok',
})
render(<SkillCenter />)
const market = screen.getByTestId('skill-marketplace-tab')
const vetterCard = await within(market).findByRole('button', { name: 'Skill Vetter' })
const weatherCard = within(market).getByRole('button', { name: 'Weather' })
const crmCard = within(market).getByRole('button', { name: 'CRM Sync' })
expect(vetterCard).toBeInTheDocument()
expect(within(market).getAllByText('Clean').length).toBeGreaterThan(0)
expect(within(market).getAllByText('Preview').length).toBeGreaterThan(0)
expect(within(weatherCard).getByText('Installed')).toBeInTheDocument()
expect(within(crmCard).getByText('API key')).toBeInTheDocument()
expect(within(vetterCard).getByText(/260\.9K|260,960/)).toBeInTheDocument()
})
it('uses marketplace-shaped loading skeletons while the catalog loads', () => {
mockedSkillMarketApi.list.mockReturnValue(new Promise(() => {}))
act(() => {
useSkillMarketStore.setState({
isLoading: true,
items: [],
})
})
render(<SkillCenter />)
expect(screen.getByTestId('skill-marketplace-loading')).toBeInTheDocument()
expect(screen.getAllByTestId('skill-card-skeleton')).toHaveLength(8)
})
it('places install decision, blocking reason, and file preview in the drawer decision area', async () => {
mockedSkillMarketApi.detail.mockResolvedValue({
detail: makeDetail({
installEligibility: { status: 'blocked', reason: 'A full package safety scan is required before install.' },
trustSummary: 'Scanner found no known malicious content, but the package scan is incomplete.',
filePreviews: [{
path: 'SKILL.md',
content: '# Skill Vetter\n\nSecurity-first skill vetting.',
language: 'markdown',
size: 512,
}],
}),
})
render(<SkillCenter />)
fireEvent.click(await screen.findByRole('button', { name: 'PPT Generator' }))
const dialog = await screen.findByRole('dialog', { name: 'PPT Generator' })
expect(within(dialog).getByRole('button', { name: 'Blocked' })).toBeDisabled()
expect(within(dialog).getByText(/full package safety scan/i)).toBeInTheDocument()
expect(within(dialog).getByText('File preview')).toBeInTheDocument()
expect(within(dialog).getByText('SKILL.md')).toBeInTheDocument()
})
it('renders raw Markdown and Python previews for an uninstalled marketplace skill', async () => {
mockedSkillMarketApi.detail.mockResolvedValue({
detail: makeDetail({

View File

@ -105,10 +105,6 @@ export function SkillCenter() {
() => items.filter((item) => TRUST_SAFE.includes(item.trustState)).length,
[items],
)
const totalDownloads = useMemo(
() => items.reduce((sum, item) => sum + (item.downloads ?? item.installs ?? 0), 0),
[items],
)
const filteredItems = useMemo(
() => items.filter((item) => matchesMarketFilter(item, marketFilter)),
[items, marketFilter],
@ -279,7 +275,6 @@ export function SkillCenter() {
total={items.length}
safeCount={safeCount}
installedCount={installedCount}
totalDownloads={totalDownloads}
loading={isLoading}
/>
@ -298,7 +293,7 @@ export function SkillCenter() {
<div className="min-h-0">
<div className="min-w-0">
{isLoading ? (
<LoadingState label={t('skillCenter.marketplace.loading')} />
<MarketplaceSkeletonGrid />
) : items.length === 0 ? (
<EmptyState
icon={<Search size={22} aria-hidden="true" />}
@ -395,7 +390,6 @@ function MarketplaceStatusLine({
total,
safeCount,
installedCount,
totalDownloads,
loading,
}: {
t: TFunction
@ -406,7 +400,6 @@ function MarketplaceStatusLine({
total: number
safeCount: number
installedCount: number
totalDownloads: number
loading: boolean
}) {
const sourceName = resolvedSource
@ -423,10 +416,9 @@ function MarketplaceStatusLine({
{sourceName}
<span className="text-[var(--color-text-tertiary)]">{statusLabel}</span>
</span>
<StatusDot label={t('skillCenter.marketplace.loadedCount', { count: String(total) })} />
<StatusDot label={`${t('skillCenter.marketplace.safe')} ${safeCount}/${total}`} />
<StatusDot label={`${t('skillCenter.marketplace.downloads')} ${compactNumber(totalDownloads)}`} />
<StatusDot label={t('skillCenter.marketplace.installedHint', { count: String(installedCount) })} />
<StatusDot label={t('skillCenter.marketplace.status.loaded', { count: String(total) })} />
<StatusDot label={t('skillCenter.marketplace.status.safe', { safe: String(safeCount), total: String(total) })} />
<StatusDot label={t('skillCenter.marketplace.status.installed', { count: String(installedCount) })} />
{loading ? (
<span className="inline-flex items-center gap-1 text-[var(--color-text-secondary)]">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-[var(--color-brand)] border-t-transparent" />
@ -453,6 +445,31 @@ function StatusDot({ label }: { label: string }) {
)
}
function StateBadge({
tone,
icon,
label,
}: {
tone: 'neutral' | 'info' | 'success' | 'warning'
icon: ReactNode
label: string
}) {
const className = tone === 'success'
? 'bg-[var(--color-success-container)] text-[var(--color-success)]'
: tone === 'warning'
? 'bg-[var(--color-warning-container)] text-[var(--color-warning)]'
: tone === 'info'
? 'bg-[var(--color-info-container)] text-[var(--color-info)]'
: 'border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)]'
return (
<span className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs ${className}`}>
{icon}
{label}
</span>
)
}
function SourceSwitch({
value,
onChange,
@ -565,6 +582,7 @@ function SkillMarketCard({
const stats = formatStats(item, t)
const sourceLabel = t(`skillCenter.marketplace.source.${item.source}`)
const summary = item.summaryZh || item.summary
const hasPreview = Boolean(item.trustSummary || item.summary || item.summaryZh)
return (
<button
@ -572,7 +590,7 @@ function SkillMarketCard({
aria-label={item.displayName}
onClick={onSelect}
className={[
'group flex min-h-[120px] flex-col rounded-lg border bg-[var(--color-surface)] p-3 text-left transition-colors',
'group flex min-h-[124px] flex-col rounded-lg border bg-[var(--color-surface)] p-3 text-left transition-[border-color,background-color,box-shadow,transform] duration-200 ease-out active:translate-y-px',
'hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] hover:shadow-sm',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]',
active ? 'border-[var(--color-border-focus)] bg-[var(--color-surface-selected)] shadow-sm' : 'border-[var(--color-border)]',
@ -608,19 +626,34 @@ function SkillMarketCard({
{summary}
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
<div className="mt-2 flex min-h-7 flex-wrap gap-1.5">
<TrustBadge state={item.trustState} />
{item.installed ? (
<span className="inline-flex items-center gap-1 rounded-md bg-[var(--color-info-container)] px-2 py-1 text-xs text-[var(--color-info)]">
<Check size={12} aria-hidden="true" />
{t('skillCenter.marketplace.installedBadge')}
</span>
<StateBadge
tone="info"
icon={<Check size={12} aria-hidden="true" />}
label={t('skillCenter.marketplace.installedBadge')}
/>
) : null}
{hasPreview ? (
<StateBadge
tone="neutral"
icon={<FileText size={12} aria-hidden="true" />}
label={t('skillCenter.marketplace.previewAvailable')}
/>
) : (
<StateBadge
tone="neutral"
icon={<FileText size={12} aria-hidden="true" />}
label={t('skillCenter.marketplace.noPreviewBadge')}
/>
)}
{item.requiresApiKey ? (
<span className="inline-flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-2 py-1 text-xs text-[var(--color-text-secondary)]">
<Lock size={12} aria-hidden="true" />
{t('skillCenter.marketplace.requiresApiKey')}
</span>
<StateBadge
tone="neutral"
icon={<Lock size={12} aria-hidden="true" />}
label={t('skillCenter.marketplace.requiresApiKey')}
/>
) : null}
</div>
@ -667,7 +700,7 @@ function SkillMarketDetailPanel({
aria-label={t('skillCenter.marketplace.selectionTitle')}
className="absolute right-0 top-0 flex h-full w-[min(520px,calc(100vw-24px))] flex-col border-l border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-dropdown)]"
>
<LoadingState label={t('skillCenter.marketplace.detailLoading')} />
<DetailDrawerSkeleton />
</aside>
</div>,
document.body,
@ -781,85 +814,97 @@ function SkillMarketDetailPanel({
? t('skillCenter.marketplace.viewInstalled')
: installLabel(t, eligibility)}
</button>
<div className="mt-3 flex flex-wrap gap-3 text-xs">
<a
href={detail.canonicalUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[var(--color-brand)] hover:underline"
>
{t('skillCenter.marketplace.openSource')}
<ExternalLink size={12} aria-hidden="true" />
</a>
{detail.upstreamUrl ? (
{eligibility.status === 'blocked' || eligibility.status === 'conflict' ? (
<div className="mt-3 rounded-md border border-[var(--color-warning)]/30 bg-[var(--color-warning-container)] px-3 py-2 text-xs leading-5 text-[var(--color-warning)]">
{eligibilityMessage(t, eligibility)}
</div>
) : null}
</div>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto p-5">
<DrawerSection title={t('skillCenter.marketplace.drawer.summary')}>
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
{summary}
</p>
</DrawerSection>
<DrawerSection title={t('skillCenter.marketplace.drawer.safety')}>
<div className="space-y-3">
{detail.trustSummary ? (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2 text-xs leading-5 text-[var(--color-text-secondary)]">
{detail.trustSummary}
</div>
) : null}
{detail.riskLabels.length > 0 ? (
<div>
<div className="mb-2 text-xs font-medium text-[var(--color-text-secondary)]">
{t('skillCenter.marketplace.riskLabels')}
</div>
<div className="flex flex-wrap gap-1.5">
{detail.riskLabels.map((label) => (
<span
key={label}
className="rounded-md bg-[var(--color-warning-container)] px-2 py-1 text-xs text-[var(--color-warning)]"
>
{t(`skillCenter.marketplace.risk.${label}`)}
</span>
))}
</div>
</div>
) : (
<div className="flex items-center gap-2 rounded-md bg-[var(--color-success-container)] px-3 py-2 text-xs text-[var(--color-success)]">
<ShieldCheck size={14} aria-hidden="true" />
{t('skillCenter.marketplace.noRiskLabels')}
</div>
)}
{detail.requiresApiKey ? (
<div className="flex items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2 text-xs text-[var(--color-text-secondary)]">
<Lock size={14} aria-hidden="true" />
{t('skillCenter.marketplace.requiresApiKey')}
</div>
) : null}
</div>
</DrawerSection>
<DrawerSection title={t('skillCenter.marketplace.filePreview')}>
<FilePreviewSection detail={detail} />
</DrawerSection>
<DrawerSection title={t('skillCenter.marketplace.drawer.metadata')}>
<dl className="grid grid-cols-2 gap-2 text-xs">
<MetaItem icon={<FileText size={13} aria-hidden="true" />} label={t('skillCenter.marketplace.files')} value={String(detail.files.length)} />
<MetaItem icon={<Download size={13} aria-hidden="true" />} label={t('skillCenter.marketplace.downloads')} value={formatNumber(detail.downloads)} />
<MetaItem icon={<Star size={13} aria-hidden="true" />} label={t('skillCenter.marketplace.stars')} value={formatNumber(detail.stars)} />
<MetaItem icon={<Clock3 size={13} aria-hidden="true" />} label={t('skillCenter.marketplace.license')} value={detail.license || '-'} />
</dl>
</DrawerSection>
<DrawerSection title={t('skillCenter.marketplace.drawer.links')}>
<div className="flex flex-wrap gap-3 text-xs">
<a
href={detail.upstreamUrl}
href={detail.canonicalUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[var(--color-brand)] hover:underline"
>
{t('skillCenter.marketplace.openUpstream')}
{t('skillCenter.marketplace.openSource')}
<ExternalLink size={12} aria-hidden="true" />
</a>
) : null}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-5">
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
{summary}
</p>
{detail.trustSummary ? (
<div className="mt-4 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2 text-xs leading-5 text-[var(--color-text-secondary)]">
{detail.trustSummary}
{detail.upstreamUrl ? (
<a
href={detail.upstreamUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[var(--color-brand)] hover:underline"
>
{t('skillCenter.marketplace.openUpstream')}
<ExternalLink size={12} aria-hidden="true" />
</a>
) : null}
</div>
) : null}
{eligibility.status === 'blocked' || eligibility.status === 'conflict' ? (
<div className="mt-4 rounded-md border border-[var(--color-warning)]/30 bg-[var(--color-warning-container)] px-3 py-2 text-xs leading-5 text-[var(--color-warning)]">
{eligibilityMessage(t, eligibility)}
</div>
) : null}
<dl className="mt-4 grid grid-cols-2 gap-2 text-xs">
<MetaItem icon={<FileText size={13} aria-hidden="true" />} label={t('skillCenter.marketplace.files')} value={String(detail.files.length)} />
<MetaItem icon={<Download size={13} aria-hidden="true" />} label={t('skillCenter.marketplace.downloads')} value={formatNumber(detail.downloads)} />
<MetaItem icon={<Star size={13} aria-hidden="true" />} label={t('skillCenter.marketplace.stars')} value={formatNumber(detail.stars)} />
<MetaItem icon={<Clock3 size={13} aria-hidden="true" />} label={t('skillCenter.marketplace.license')} value={detail.license || '-'} />
</dl>
{detail.riskLabels.length > 0 ? (
<div className="mt-4">
<div className="mb-2 text-xs font-medium text-[var(--color-text-secondary)]">
{t('skillCenter.marketplace.riskLabels')}
</div>
<div className="flex flex-wrap gap-1.5">
{detail.riskLabels.map((label) => (
<span
key={label}
className="rounded-md bg-[var(--color-warning-container)] px-2 py-1 text-xs text-[var(--color-warning)]"
>
{t(`skillCenter.marketplace.risk.${label}`)}
</span>
))}
</div>
</div>
) : (
<div className="mt-4 flex items-center gap-2 rounded-md bg-[var(--color-success-container)] px-3 py-2 text-xs text-[var(--color-success)]">
<ShieldCheck size={14} aria-hidden="true" />
{t('skillCenter.marketplace.noRiskLabels')}
</div>
)}
{detail.requiresApiKey ? (
<div className="mt-4 flex items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2 text-xs text-[var(--color-text-secondary)]">
<Lock size={14} aria-hidden="true" />
{t('skillCenter.marketplace.requiresApiKey')}
</div>
) : null}
<FilePreviewSection detail={detail} />
</DrawerSection>
</div>
</aside>
</div>,
@ -884,7 +929,7 @@ function FilePreviewSection({ detail }: { detail: SkillMarketDetail }) {
return null
}
return (
<div className="mt-4 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-secondary)]">
<FileText size={13} aria-hidden="true" />
{t('skillCenter.marketplace.previewUnavailable')}
@ -897,11 +942,7 @@ function FilePreviewSection({ detail }: { detail: SkillMarketDetail }) {
}
return (
<div className="mt-4">
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-secondary)]">
<FileText size={13} aria-hidden="true" />
{t('skillCenter.marketplace.filePreview')}
</div>
<div>
<div className="space-y-3">
{previews.map((preview) => (
<section
@ -938,6 +979,17 @@ function FilePreviewSection({ detail }: { detail: SkillMarketDetail }) {
)
}
function DrawerSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="border-t border-[var(--color-border)] pt-4 first:border-t-0 first:pt-0">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-normal text-[var(--color-text-tertiary)]">
{title}
</h4>
{children}
</section>
)
}
function DecisionHint({
icon,
title,
@ -1048,11 +1100,50 @@ function EligibilityBadge({ eligibility }: { eligibility: SkillMarketInstallElig
)
}
function LoadingState({ label }: { label: string }) {
function MarketplaceSkeletonGrid() {
const t = useTranslation()
return (
<div className="flex min-h-[260px] items-center justify-center rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-6 py-8 text-sm text-[var(--color-text-secondary)]">
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-[var(--color-brand)] border-t-transparent" />
{label}
<div
data-testid="skill-marketplace-loading"
aria-label={t('skillCenter.marketplace.loadingCard')}
className="grid gap-2.5 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
data-testid="skill-card-skeleton"
className="min-h-[124px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-3"
>
<div className="h-4 w-2/3 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
<div className="mt-3 h-3 w-full animate-pulse rounded bg-[var(--color-surface-container-high)]" />
<div className="mt-2 h-3 w-5/6 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
<div className="mt-4 flex gap-2">
<div className="h-5 w-16 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
<div className="h-5 w-20 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
</div>
</div>
))}
</div>
)
}
function DetailDrawerSkeleton() {
return (
<div className="flex h-full flex-col">
<div className="border-b border-[var(--color-border)] px-5 py-4">
<div className="h-5 w-28 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
<div className="mt-3 h-6 w-2/3 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
</div>
<div className="border-b border-[var(--color-border)] p-5">
<div className="h-10 w-full animate-pulse rounded bg-[var(--color-surface-container-high)]" />
<div className="mt-3 h-4 w-3/4 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
</div>
<div className="space-y-3 p-5">
<div className="h-4 w-full animate-pulse rounded bg-[var(--color-surface-container-high)]" />
<div className="h-4 w-5/6 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
<div className="h-40 w-full animate-pulse rounded bg-[var(--color-surface-container-high)]" />
</div>
</div>
)
}
@ -1126,13 +1217,13 @@ function matchesMarketFilter(item: SkillMarketItem, filter: MarketFilter) {
function formatStats(item: SkillMarketItem, t: TFunction): string {
if (typeof item.downloads === 'number') {
return `${compactNumber(item.downloads)} ${t('skillCenter.marketplace.downloads')}`
return t('skillCenter.marketplace.cardMeta.downloads', { count: formatNumber(item.downloads) })
}
if (typeof item.installs === 'number') {
return `${compactNumber(item.installs)} ${t('skillCenter.marketplace.sort.installs')}`
}
if (typeof item.stars === 'number') {
return `${compactNumber(item.stars)} ${t('skillCenter.marketplace.stars')}`
return t('skillCenter.marketplace.cardMeta.stars', { count: formatNumber(item.stars) })
}
return ''
}