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:
程序员阿江(Relakkes) 2026-07-10 03:33:09 +08:00
parent 25f19fad69
commit 203eb6b00d
13 changed files with 451 additions and 246 deletions

View File

@ -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>
)
}

View File

@ -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>
)

View File

@ -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>
)

View 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()
})
})

View File

@ -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>
)
}

View File

@ -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 })

View File

@ -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>
)

View File

@ -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>

View File

@ -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 />)

View File

@ -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>
)
}

View File

@ -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()
})
})

View File

@ -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>
)

View File

@ -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>
)
})}