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 { useTranslation } from '../../i18n'
import { Dropdown } from '../shared/Dropdown' import { Dropdown } from '../shared/Dropdown'
import { useMarketStore, type MarketFilters } from '../../stores/marketStore' import { useMarketStore, type MarketFilters } from '../../stores/marketStore'
@ -9,19 +10,19 @@ import type {
function FilterTrigger({ label, value, active }: { label: string; value: string; active: boolean }) { function FilterTrigger({ label, value, active }: { label: string; value: string; active: boolean }) {
return ( return (
<span <button
className={`inline-flex min-h-9 items-center gap-1.5 rounded-xl border px-3 text-xs transition-colors ${ 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 active
? 'border-[var(--color-brand)] bg-[var(--color-surface)] text-[var(--color-brand)]' ? 'border-[var(--color-brand)]/35 bg-[var(--color-primary-fixed)] 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-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="font-medium">{value}</span>
<span className="material-symbols-outlined text-[14px]" aria-hidden> <ChevronDown className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
expand_more </button>
</span>
</span>
) )
} }

View File

@ -1,19 +1,20 @@
import { CheckCircle2, CircleSlash2, Download, type LucideIcon } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import type { InstallState } from '../../types/market' import type { InstallState } from '../../types/market'
const STYLES: Record<InstallState, { icon: string; className: string }> = { const STYLES: Record<InstallState, { icon: LucideIcon; className: string }> = {
installed: { installed: {
icon: 'check_circle', icon: CheckCircle2,
className: 'bg-[var(--color-success-container)] text-[var(--color-success)]', className: 'border-[var(--color-success)]/20 bg-[var(--color-success-container)] text-[var(--color-success)]',
}, },
installable: { 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). // 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': { 'not-installable': {
icon: 'block', icon: CircleSlash2,
className: 'bg-[var(--color-error-container)] text-[var(--color-error)]', 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 }) { export function InstallStateBadge({ state, className = '' }: { state: InstallState; className?: string }) {
const t = useTranslation() const t = useTranslation()
const style = STYLES[state] const style = STYLES[state]
const Icon = style.icon
return ( return (
<span <span
data-testid={`install-badge-${state}`} 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> <Icon className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
{style.icon}
</span>
{t(LABEL_KEYS[state])} {t(LABEL_KEYS[state])}
</span> </span>
) )

View File

@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { ShieldAlert, X } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
const STORAGE_KEY = 'cc-haha-market-disclaimer-dismissed' const STORAGE_KEY = 'cc-haha-market-disclaimer-dismissed'
@ -26,12 +27,10 @@ export function MarketDisclaimer() {
<div <div
role="note" role="note"
data-testid="market-disclaimer" 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> <ShieldAlert className="mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--color-warning)]" strokeWidth={2} aria-hidden="true" />
gpp_maybe <p className="min-w-0 flex-1 text-[11px] leading-[18px] text-[var(--color-text-secondary)] sm:text-xs sm:leading-5">
</span>
<p className="min-w-0 flex-1 text-xs leading-5 text-[var(--color-text-secondary)]">
<span className="font-semibold text-[var(--color-text-primary)]">{t('market.disclaimer.title')}</span>{' '} <span className="font-semibold text-[var(--color-text-primary)]">{t('market.disclaimer.title')}</span>{' '}
{t('market.disclaimer.body')} {t('market.disclaimer.body')}
</p> </p>
@ -46,11 +45,9 @@ export function MarketDisclaimer() {
// Persisting is best-effort; the banner stays dismissed for this session. // 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> <X className="h-4 w-4" strokeWidth={2} aria-hidden="true" />
close
</span>
</button> </button>
</div> </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 { useEffect } from 'react'
import { CloudOff, PackageSearch, RefreshCw, Search, Store, X } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useMarketStore } from '../../stores/marketStore' import { useMarketStore } from '../../stores/marketStore'
import { FilterBar } from './FilterBar' import { FilterBar } from './FilterBar'
@ -35,73 +36,81 @@ export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string
const hasQuery = query.trim().length > 0 const hasQuery = query.trim().length > 0
return ( return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto"> <div className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-[var(--color-surface-container-lowest)]">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-6"> <header className="shrink-0 border-b border-[var(--color-border)]/70 bg-[var(--color-surface)]">
<MarketDisclaimer /> <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">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-5 py-5"> <div className="flex min-w-0 items-center gap-3.5">
<div className="flex flex-wrap items-start justify-between gap-4"> <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="min-w-0">
<div className="mb-1 flex items-center gap-2.5"> <h1 className="text-[22px] font-semibold leading-7 tracking-[-0.025em] text-[var(--color-text-primary)]">
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">storefront</span> {t('market.title')}
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">{t('market.title')}</h2> </h1>
</div> <p className="mt-0.5 max-w-2xl text-[13px] leading-5 text-[var(--color-text-secondary)]">
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">{t('market.subtitle')}</p> {t('market.subtitle')}
</p>
</div> </div>
<SourceStatusBar sources={sources} />
</div> </div>
<SourceStatusBar sources={sources} />
</div>
</header>
<div className="mt-4 flex flex-wrap items-center gap-2"> <div className="mx-auto flex w-full max-w-[1400px] flex-col gap-4 px-6 py-5 lg:px-8">
<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"> <MarketDisclaimer />
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">search</span>
<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 <input
data-testid="market-search-input" data-testid="market-search-input"
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder={t('market.searchPlaceholder')} 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 && ( {query && (
<button <button
type="button" type="button"
aria-label={t('market.clearSearch')} aria-label={t('market.clearSearch')}
onClick={() => setQuery('')} 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> </button>
)} )}
</div> </div>
<FilterBar /> <FilterBar />
</div> </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> </section>
{isLoading && ( {!isLoading && items.length > 0 && (
<div className="flex flex-col items-center gap-3 py-16" data-testid="market-loading"> <div className="flex items-center gap-3 px-0.5">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[var(--color-brand)] border-t-transparent" /> <p className="flex-shrink-0 text-[11px] font-medium tabular-nums text-[var(--color-text-tertiary)]">
<p className="text-sm text-[var(--color-text-tertiary)]">{t('market.loading')}</p> {t('market.resultCount', { count: String(items.length) })}
</p>
<div className="h-px flex-1 bg-[var(--color-border)]/60" />
</div> </div>
)} )}
{isLoading && <MarketGridSkeleton label={t('market.loading')} />}
{!isLoading && error && ( {!isLoading && error && (
<div <div
data-testid="market-error" 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="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> <p className="max-w-md break-words text-xs text-[var(--color-text-tertiary)]">{error}</p>
<button <button
type="button" type="button"
onClick={() => void fetchList({ reset: true })} 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')} {t('market.retry')}
</button> </button>
</div> </div>
@ -110,11 +119,13 @@ export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string
{!isLoading && !error && items.length === 0 && ( {!isLoading && !error && items.length === 0 && (
<div <div
data-testid="market-empty" 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 ? (
{hasQuery || hasActiveFilters ? 'search_off' : 'storefront'} <PackageSearch className="mx-auto mb-3 h-9 w-9 text-[var(--color-text-tertiary)]" strokeWidth={1.6} aria-hidden="true" />
</span> ) : (
<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)]"> <p className="text-sm text-[var(--color-text-tertiary)]">
{hasQuery || hasActiveFilters ? t('market.emptySearch') : t('market.empty')} {hasQuery || hasActiveFilters ? t('market.emptySearch') : t('market.empty')}
</p> </p>
@ -126,7 +137,7 @@ export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string
{!isLoading && items.length > 0 && ( {!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) => ( {items.map((skill) => (
<SkillCard <SkillCard
key={skill.id} key={skill.id}
@ -139,13 +150,13 @@ export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string
</div> </div>
{nextCursor && ( {nextCursor && (
<div className="flex justify-center pb-4"> <div className="flex justify-center py-2 pb-5">
<button <button
type="button" type="button"
data-testid="market-load-more" data-testid="market-load-more"
disabled={isLoadingMore} disabled={isLoadingMore}
onClick={() => void loadMore()} 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 && ( {isLoadingMore && (
<span className="h-3.5 w-3.5 animate-spin rounded-full border border-current border-t-transparent" aria-hidden /> <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> </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 { useCallback, useMemo } from 'react'
import { ArrowLeft, CircleAlert, Download, KeyRound, RefreshCw, Trash2 } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useMarketStore } from '../../stores/marketStore' import { useMarketStore } from '../../stores/marketStore'
import { SkillDetailView, type SkillDetailMetaItem } from './SkillDetailView' import { SkillDetailView, type SkillDetailMetaItem } from './SkillDetailView'
@ -66,7 +67,7 @@ export function MarketSkillDetail({
if (detail.requiresApiKey) { if (detail.requiresApiKey) {
items.push({ items.push({
label: t('market.detail.requiresApiKey'), 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 return items
@ -76,32 +77,58 @@ export function MarketSkillDetail({
if (isDetailLoading) { if (isDetailLoading) {
return ( return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-20" data-testid="market-detail-loading"> <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="h-6 w-6 animate-spin rounded-full border-2 border-[var(--color-brand)] border-t-transparent" /> <div className="mx-auto w-full max-w-[1320px] px-6 py-6 lg:px-8">
<p className="text-sm text-[var(--color-text-tertiary)]">{t('market.loading')}</p> <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> </div>
) )
} }
if (detailError || !detail) { if (detailError || !detail) {
return ( return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-20 text-center" data-testid="market-detail-error"> <div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-20 text-center" data-testid="market-detail-error">
<span className="material-symbols-outlined text-[36px] text-[var(--color-error)]">error</span> <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> <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>} {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"> <div className="mt-1 flex items-center gap-2">
<button <button
type="button" type="button"
onClick={() => void refreshDetail(selectedId)} 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')} {t('market.retry')}
</button> </button>
<button <button
type="button" type="button"
onClick={backToList} 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')} {t('market.detail.back')}
</button> </button>
@ -125,12 +152,12 @@ export function MarketSkillDetail({
data-testid="market-install-button" data-testid="market-install-button"
disabled={installing} disabled={installing}
onClick={() => onRequestInstall(detail.id)} 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 ? ( {installing ? (
<span className="h-3.5 w-3.5 animate-spin rounded-full border border-current border-t-transparent" aria-hidden /> <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')} {installing ? t('market.install.installing') : t('market.install.action')}
</button> </button>
@ -141,12 +168,12 @@ export function MarketSkillDetail({
data-testid="market-uninstall-button" data-testid="market-uninstall-button"
disabled={installing} disabled={installing}
onClick={() => onRequestUninstall(detail.id)} 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 ? ( {installing ? (
<span className="h-3.5 w-3.5 animate-spin rounded-full border border-current border-t-transparent" aria-hidden /> <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')} {installing ? t('market.uninstall.uninstalling') : t('market.uninstall.action')}
</button> </button>
@ -164,9 +191,9 @@ export function MarketSkillDetail({
{installError && installError.id === detail.id && ( {installError && installError.id === detail.id && (
<div <div
data-testid="market-install-error" 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"> <span className="break-words">
{installError.kind === 'generic' {installError.kind === 'generic'
? t('market.installError.generic', { message: installError.message }) ? 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 { useTranslation } from '../../i18n'
import type { SecurityStatus } from '../../types/market' import type { SecurityStatus } from '../../types/market'
const STYLES: Record<SecurityStatus, { icon: string; className: string }> = { const STYLES: Record<SecurityStatus, { icon: LucideIcon; className: string }> = {
verified: { verified: {
icon: 'verified', icon: BadgeCheck,
className: 'bg-[var(--color-success-container)] text-[var(--color-success)]', className: 'border-[var(--color-success)]/20 bg-[var(--color-success-container)] text-[var(--color-success)]',
}, },
benign: { benign: {
icon: 'gpp_good', icon: ShieldCheck,
className: 'bg-[var(--color-success-container)] text-[var(--color-success)]', className: 'border-[var(--color-success)]/20 bg-[var(--color-success-container)] text-[var(--color-success)]',
}, },
unknown: { 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. // 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: { flagged: {
icon: 'gpp_maybe', icon: ShieldAlert,
className: 'bg-[var(--color-error-container)] text-[var(--color-error)]', className: 'border-[var(--color-error)]/20 bg-[var(--color-error-container)] text-[var(--color-error)]',
}, },
} }
export function SecurityBadge({ status, className = '' }: { status: SecurityStatus; className?: string }) { export function SecurityBadge({ status, className = '' }: { status: SecurityStatus; className?: string }) {
const t = useTranslation() const t = useTranslation()
const style = STYLES[status] const style = STYLES[status]
const Icon = style.icon
return ( return (
<span <span
data-testid={`security-badge-${status}`} data-testid={`security-badge-${status}`}
title={t(`market.securityHint.${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> <Icon className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
{style.icon}
</span>
{t(`market.security.${status}`)} {t(`market.security.${status}`)}
</span> </span>
) )

View File

@ -1,12 +1,21 @@
import type { NormalizedSkill } from '../../types/market' import type { NormalizedSkill } from '../../types/market'
/** Deterministic hue from a string so every skill keeps a stable identity color. */ const AVATAR_GRADIENTS = [
function hashHue(input: string): number { ['#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 let hash = 0
for (let i = 0; i < input.length; i++) { for (let i = 0; i < input.length; i++) {
hash = (hash * 31 + input.charCodeAt(i)) | 0 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). */ /** 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. * Skill icon with a deterministic letter-avatar fallback. The palette stays
* Upstream sources rarely provide `iconUrl`, so the fallback carries the * deliberately muted so a catalog of community skills still reads as one
* visual identity: same skill name same color + initial, every render. * product rather than a wall of unrelated app icons.
*/ */
export function SkillAvatar({ export function SkillAvatar({
skill, skill,
@ -36,11 +45,11 @@ export function SkillAvatar({
alt="" alt=""
loading="lazy" loading="lazy"
style={{ width: size, height: size }} 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 ( return (
<span <span
aria-hidden aria-hidden
@ -49,9 +58,10 @@ export function SkillAvatar({
width: size, width: size,
height: size, height: size,
fontSize: Math.round(size * 0.45), 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)} {initialOf(skill.name)}
</span> </span>

View File

@ -65,10 +65,22 @@ describe('SkillCard', () => {
expect(onInstall).toHaveBeenCalledWith('clawhub:demo') expect(onInstall).toHaveBeenCalledWith('clawhub:demo')
expect(onOpen).not.toHaveBeenCalled() expect(onOpen).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Demo Skill')) fireEvent.click(screen.getByRole('button', { name: 'Demo Skill' }))
expect(onOpen).toHaveBeenCalledWith('clawhub:demo') 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', () => { it('disables the quick-install button while installing', () => {
render(<SkillCard skill={makeSkill()} onOpen={vi.fn()} onInstall={vi.fn()} 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 { useTranslation } from '../../i18n'
import type { NormalizedSkill } from '../../types/market' import type { NormalizedSkill } from '../../types/market'
import { InstallStateBadge } from './InstallStateBadge' import { InstallStateBadge } from './InstallStateBadge'
@ -28,54 +29,64 @@ export function SkillCard({
const showInstallButton = Boolean(onInstall) && skill.installState === 'installable' const showInstallButton = Boolean(onInstall) && skill.installState === 'installable'
return ( return (
<div <article
data-testid={`market-skill-card-${skill.id}`} 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)]" 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: '196px' }} style={{ contentVisibility: 'auto', containIntrinsicSize: '212px' }}
role="button"
tabIndex={0}
onClick={() => onOpen(skill.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onOpen(skill.id)
}
}}
> >
<div className="flex items-start gap-3"> <button
<SkillAvatar skill={skill} size={40} /> 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="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-start gap-2">
<span className="truncate text-sm font-semibold text-[var(--color-text-primary)]">{skill.name}</span> <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 && ( {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} v{skill.version}
</span> </span>
)} )}
</div> </div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)]"> <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}`)} {t(`market.source.${skill.source}`)}
</span> </span>
{skill.author.handle && ( {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>
</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> </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')} {skill.summary || t('market.detail.noDescription')}
</p> </p>
{skill.tags.length > 0 && ( {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) => ( {skill.tags.slice(0, MAX_VISIBLE_TAGS).map((tag) => (
<span <span
key={tag} 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> </span>
))} ))}
{extraTags > 0 && ( {extraTags > 0 && (
@ -86,7 +97,7 @@ export function SkillCard({
</div> </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"> <div className="flex min-w-0 flex-wrap items-center gap-1.5">
<SecurityBadge status={skill.securityStatus} /> <SecurityBadge status={skill.securityStatus} />
{/* The quick-install button already communicates "installable" — skip the badge when the button renders. */} {/* 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} /> <InstallStateBadge state={skill.installState} />
)} )}
</div> </div>
<div className="ml-auto flex flex-shrink-0 items-center gap-2 text-[11px] tabular-nums text-[var(--color-text-tertiary)]"> <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-0.5" title={t('market.detail.downloads')}> <span className="inline-flex items-center gap-1" title={t('market.detail.downloads')}>
<span className="material-symbols-outlined text-[13px]" aria-hidden>download</span> <Download className="h-3.5 w-3.5" strokeWidth={1.8} aria-hidden="true" />
{formatCount(skill.stats.downloads)} {formatCount(skill.stats.downloads)}
</span> </span>
{typeof skill.stats.stars === 'number' && skill.stats.stars > 0 && ( {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="inline-flex items-center gap-1" title={t('market.detail.stars')}>
<span className="material-symbols-outlined text-[13px]" aria-hidden>star</span> <Star className="h-3.5 w-3.5" strokeWidth={1.8} aria-hidden="true" />
{formatCount(skill.stats.stars)} {formatCount(skill.stats.stars)}
</span> </span>
)} )}
@ -109,22 +120,19 @@ export function SkillCard({
<button <button
type="button" type="button"
disabled={installing} disabled={installing}
onClick={(e) => { onClick={() => onInstall?.(skill.id)}
e.stopPropagation() 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"
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"
> >
{installing ? ( {installing ? (
<span className="h-3 w-3 animate-spin rounded-full border border-current border-t-transparent" aria-hidden /> <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')} {installing ? t('market.install.installing') : t('market.install.action')}
</button> </button>
)} )}
</div> </div>
</div> </footer>
</div> </article>
) )
} }

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' 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' import '@testing-library/jest-dom'
vi.mock('../markdown/MarkdownRenderer', () => ({ vi.mock('../markdown/MarkdownRenderer', () => ({
@ -113,6 +113,9 @@ describe('SkillDetailView', () => {
it('renders custom actions in the decision area', () => { it('renders custom actions in the decision area', () => {
renderView({ actions: <button type="button">Install now</button> }) 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 { useState, type ReactNode } from 'react'
import { ArrowLeft, CircleSlash2, FileText, Folder } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import type { import type {
InstallState, InstallState,
@ -10,6 +11,7 @@ import { InstallStateBadge } from './InstallStateBadge'
import { SecurityBadge } from './SecurityBadge' import { SecurityBadge } from './SecurityBadge'
import { FilePreview, type PreviewFile, type PreviewFileContent } from './FilePreview' import { FilePreview, type PreviewFile, type PreviewFileContent } from './FilePreview'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { SkillAvatar } from './SkillAvatar'
export type SkillDetailMetaItem = { export type SkillDetailMetaItem = {
label: string label: string
@ -48,79 +50,70 @@ export function SkillDetailView(props: SkillDetailViewProps) {
const [tab, setTab] = useState<'overview' | 'files'>('overview') const [tab, setTab] = useState<'overview' | 'files'>('overview')
return ( return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto" data-testid="skill-detail-view"> <div
<div className="mx-auto flex w-full max-w-5xl flex-col gap-5 px-6 py-6"> 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 <button
type="button" type="button"
onClick={props.onBack} 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} {props.backLabel}
</button> </button>
{/* Install decision area */} <header className="mt-5 border-b border-[var(--color-border)]/70 pb-6">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-5 py-5"> <div className="flex min-w-0 items-start gap-4 sm:gap-5">
<div className="flex flex-wrap items-start justify-between gap-4"> <SkillAvatar skill={{ name: props.name, iconUrl: props.iconUrl }} size={64} />
<div className="flex min-w-0 items-start gap-3.5"> <div className="min-w-0 flex-1 pt-0.5">
{props.iconUrl ? ( <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)]">
<img <span>{props.sourceLabel}</span>
src={props.iconUrl} {props.version && (
alt="" <>
className="h-12 w-12 flex-shrink-0 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container)] object-cover" <span aria-hidden>·</span>
/> <span className="font-mono font-normal normal-case tracking-normal">v{props.version}</span>
) : ( </>
<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>
)} )}
</div> </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> </div>
{props.actions && <div className="flex flex-shrink-0 items-center gap-2">{props.actions}</div>}
</div> </div>
{props.installState === 'not-installable' && props.notInstallableReason && ( {props.installState === 'not-installable' && props.notInstallableReason && (
<div <div
data-testid="market-not-installable-reason" 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> <CircleSlash2 className="mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--color-error)]" strokeWidth={2} aria-hidden="true" />
block
</span>
<span>{t(`market.reason.${props.notInstallableReason}`)}</span> <span>{t(`market.reason.${props.notInstallableReason}`)}</span>
</div> </div>
)} )}
{props.securityReports && props.securityReports.length > 0 && ( {props.securityReports && props.securityReports.length > 0 && (
<div className="mt-4 flex flex-wrap items-center gap-2" data-testid="market-security-reports"> <div
<span className="text-[11px] font-medium uppercase tracking-wide text-[var(--color-text-tertiary)]"> 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')} {t('market.detail.securityReport')}
</span> </span>
{props.securityReports.map((report) => ( {props.securityReports.map((report) => (
<span <span
key={report.vendor} 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> <span className="font-medium text-[var(--color-text-primary)]">{report.vendor}</span>
{report.statusText} {report.statusText}
@ -141,61 +134,104 @@ export function SkillDetailView(props: SkillDetailViewProps) {
)} )}
{props.banner} {props.banner}
</section> </header>
{/* Meta grid */} <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_280px] lg:items-start">
{props.meta.length > 0 && ( <main className="min-w-0">
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"> <div
{props.meta.map((item) => ( role="tablist"
<div aria-label={props.name}
key={item.label} className="flex items-center gap-1 border-b border-[var(--color-border)]"
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)]'
}`}
> >
<span className="material-symbols-outlined text-[16px]" aria-hidden> {(['overview', 'files'] as const).map((key) => {
{key === 'overview' ? 'article' : 'folder'} const active = tab === key
</span> const Icon = key === 'overview' ? FileText : Folder
{t(`market.detail.${key}`)} return (
{key === 'files' && ( <button
<span className="rounded-full bg-[var(--color-surface-container-high)] px-1.5 text-[10px] text-[var(--color-text-tertiary)]"> key={key}
{props.files.length} id={`skill-detail-tab-${key}-trigger`}
</span> type="button"
)} role="tab"
</button> aria-selected={active}
))} aria-controls={`skill-detail-${key}-panel`}
</div> 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' && ( {tab === 'overview' && (
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-6 py-5" data-testid="skill-detail-overview"> <section
{props.description.trim() ? ( id="skill-detail-overview-panel"
<MarkdownRenderer content={props.description} variant="document" className="mx-auto max-w-[72ch]" /> role="tabpanel"
) : ( aria-labelledby="skill-detail-tab-overview-trigger"
<p className="py-6 text-center text-sm text-[var(--color-text-tertiary)]">{t('market.detail.noDescription')}</p> 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>
</div> </div>
) )

View File

@ -27,7 +27,10 @@ export function SourceStatusBar({
}) { }) {
const t = useTranslation() const t = useTranslation()
return ( 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) => { {MARKET_SOURCES.map((source) => {
const info = sources[source] const info = sources[source]
if (!info) return null if (!info) return null
@ -40,11 +43,11 @@ export function SourceStatusBar({
key={source} key={source}
data-testid={`market-source-status-${source}`} data-testid={`market-source-status-${source}`}
title={info.error || undefined} 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 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> </span>
) )
})} })}