feat(desktop): 技能市场增加免责声明并优化卡片与主题适配

- 市场顶部新增可关闭的第三方技能免责声明,提示安装前先用 AI 扫描
- 图标缺失时改用按技能名生成的确定性彩色首字母头像
- 卡片视觉优化:隐藏冗余的"可安装"徽章、底部分隔线、hover 反馈
- 安全徽章增加 tooltip 解释各状态含义(五语言)
- 修复暗色主题下筛选器激活态对比度 1.3:1 不可读的问题,
  统一改用 brand-on-neutral 组合使三主题均达 WCAG AA
This commit is contained in:
程序员阿江(Relakkes) 2026-07-09 15:07:30 +08:00
parent 25ebc05a42
commit 55ba2392c1
14 changed files with 256 additions and 30 deletions

View File

@ -12,7 +12,7 @@ function FilterTrigger({ label, value, active }: { label: string; value: string;
<span
className={`inline-flex min-h-9 items-center gap-1.5 rounded-xl border px-3 text-xs transition-colors ${
active
? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)] text-[var(--color-brand)]'
? '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)]'
}`}
>

View File

@ -8,7 +8,8 @@ const STYLES: Record<InstallState, { icon: string; className: string }> = {
},
installable: {
icon: 'download',
className: 'bg-[var(--color-primary-fixed)] text-[var(--color-brand)]',
// 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)]',
},
'not-installable': {
icon: 'block',

View File

@ -0,0 +1,40 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom'
import { MarketDisclaimer } from './MarketDisclaimer'
import { useSettingsStore } from '../../stores/settingsStore'
const STORAGE_KEY = 'cc-haha-market-disclaimer-dismissed'
beforeEach(() => {
localStorage.clear()
useSettingsStore.setState({ locale: 'en' })
})
describe('MarketDisclaimer', () => {
it('renders the disclaimer with the AI-scan advice', () => {
render(<MarketDisclaimer />)
expect(screen.getByTestId('market-disclaimer')).toBeInTheDocument()
expect(screen.getByText('Use third-party skills with care.')).toBeInTheDocument()
expect(screen.getByText(/have AI scan them for safety first/)).toBeInTheDocument()
})
it('dismisses on click and persists the dismissal', () => {
render(<MarketDisclaimer />)
fireEvent.click(screen.getByLabelText('Dismiss disclaimer'))
expect(screen.queryByTestId('market-disclaimer')).not.toBeInTheDocument()
expect(localStorage.getItem(STORAGE_KEY)).toBe('1')
})
it('stays hidden when previously dismissed', () => {
localStorage.setItem(STORAGE_KEY, '1')
render(<MarketDisclaimer />)
expect(screen.queryByTestId('market-disclaimer')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,57 @@
import { useState } from 'react'
import { useTranslation } from '../../i18n'
const STORAGE_KEY = 'cc-haha-market-disclaimer-dismissed'
function readDismissed(): boolean {
try {
return typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1'
} catch {
return false
}
}
/**
* Top-of-market disclaimer: skills come from third-party sources and are not
* audited locally users should review (ideally AI-scan) them before install.
* Dismissal is persisted so it only shows until acknowledged.
*/
export function MarketDisclaimer() {
const t = useTranslation()
const [dismissed, setDismissed] = useState(readDismissed)
if (dismissed) return null
return (
<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"
>
<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)]">
<span className="font-semibold text-[var(--color-text-primary)]">{t('market.disclaimer.title')}</span>{' '}
{t('market.disclaimer.body')}
</p>
<button
type="button"
aria-label={t('market.disclaimer.dismiss')}
onClick={() => {
setDismissed(true)
try {
localStorage.setItem(STORAGE_KEY, '1')
} catch {
// 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)]"
>
<span className="material-symbols-outlined text-[16px]" aria-hidden>
close
</span>
</button>
</div>
)
}

View File

@ -2,6 +2,7 @@ import { useEffect } from 'react'
import { useTranslation } from '../../i18n'
import { useMarketStore } from '../../stores/marketStore'
import { FilterBar } from './FilterBar'
import { MarketDisclaimer } from './MarketDisclaimer'
import { SkillCard } from './SkillCard'
import { SourceStatusBar } from './SourceStatusBar'
@ -35,7 +36,8 @@ export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string
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-5 px-6 py-6">
<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="min-w-0">

View File

@ -12,7 +12,8 @@ const STYLES: Record<SecurityStatus, { icon: string; className: string }> = {
},
unknown: {
icon: 'shield_question',
className: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]',
// 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)]',
},
flagged: {
icon: 'gpp_maybe',
@ -26,6 +27,7 @@ export function SecurityBadge({ status, className = '' }: { status: SecurityStat
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}`}
>
<span className="material-symbols-outlined text-[12px]" aria-hidden>

View File

@ -0,0 +1,59 @@
import type { NormalizedSkill } from '../../types/market'
/** Deterministic hue from a string so every skill keeps a stable identity color. */
function hashHue(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
}
/** First visible character of the name, uppercased (handles CJK and multi-byte chars). */
function initialOf(name: string): string {
const first = Array.from(name.trim())[0]
return first ? first.toUpperCase() : '?'
}
/**
* 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.
*/
export function SkillAvatar({
skill,
size = 40,
className = '',
}: {
skill: Pick<NormalizedSkill, 'name' | 'iconUrl'>
size?: number
className?: string
}) {
if (skill.iconUrl) {
return (
<img
src={skill.iconUrl}
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}`}
/>
)
}
const hue = hashHue(skill.name)
return (
<span
aria-hidden
data-testid="skill-avatar-fallback"
style={{
width: size,
height: size,
fontSize: Math.round(size * 0.45),
background: `linear-gradient(135deg, hsl(${hue} 68% 52%), hsl(${(hue + 28) % 360} 64% 38%))`,
}}
className={`inline-flex flex-shrink-0 select-none items-center justify-center rounded-xl font-semibold text-white ${className}`}
>
{initialOf(skill.name)}
</span>
)
}

View File

@ -81,4 +81,43 @@ describe('SkillCard', () => {
expect(screen.getByTestId('security-badge-flagged')).toBeInTheDocument()
expect(screen.getByText('Flagged')).toBeInTheDocument()
})
it('hides the redundant installable badge when the quick-install button is shown', () => {
render(<SkillCard skill={makeSkill()} onOpen={vi.fn()} onInstall={vi.fn()} />)
expect(screen.getByText('Install')).toBeInTheDocument()
expect(screen.queryByTestId('install-badge-installable')).not.toBeInTheDocument()
})
it('renders a deterministic letter avatar when iconUrl is missing', () => {
render(<SkillCard skill={makeSkill()} onOpen={vi.fn()} />)
const avatar = screen.getByTestId('skill-avatar-fallback')
expect(avatar).toHaveTextContent('D')
const background = avatar.style.background
// Same name → same identity color on re-render.
render(<SkillCard skill={makeSkill({ id: 'clawhub:demo-2' })} onOpen={vi.fn()} />)
const second = screen.getAllByTestId('skill-avatar-fallback')[1]!
expect(second.style.background).toBe(background)
})
it('renders the icon image when iconUrl is provided', () => {
const { container } = render(
<SkillCard skill={makeSkill({ iconUrl: 'https://example.com/icon.png' })} onOpen={vi.fn()} />,
)
const img = container.querySelector('img')
expect(img).toHaveAttribute('src', 'https://example.com/icon.png')
expect(screen.queryByTestId('skill-avatar-fallback')).not.toBeInTheDocument()
})
it('explains the security status via tooltip', () => {
render(<SkillCard skill={makeSkill({ securityStatus: 'unknown' })} onOpen={vi.fn()} />)
expect(screen.getByTestId('security-badge-unknown')).toHaveAttribute(
'title',
'No security audit data from the source — review the files before installing.',
)
})
})

View File

@ -2,6 +2,7 @@ import { useTranslation } from '../../i18n'
import type { NormalizedSkill } from '../../types/market'
import { InstallStateBadge } from './InstallStateBadge'
import { SecurityBadge } from './SecurityBadge'
import { SkillAvatar } from './SkillAvatar'
function formatCount(value: number): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
@ -24,12 +25,13 @@ export function SkillCard({
}) {
const t = useTranslation()
const extraTags = Math.max(0, skill.tags.length - MAX_VISIBLE_TAGS)
const showInstallButton = Boolean(onInstall) && skill.installState === 'installable'
return (
<div
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 hover:border-[var(--color-border-focus)] hover:shadow-[var(--shadow-button-primary)]"
style={{ contentVisibility: 'auto', containIntrinsicSize: '190px' }}
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)}
@ -41,18 +43,7 @@ export function SkillCard({
}}
>
<div className="flex items-start gap-3">
{skill.iconUrl ? (
<img
src={skill.iconUrl}
alt=""
loading="lazy"
className="h-9 w-9 flex-shrink-0 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container)] object-cover"
/>
) : (
<span className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[18px]">auto_awesome</span>
</span>
)}
<SkillAvatar skill={skill} size={40} />
<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>
@ -62,11 +53,8 @@ export function SkillCard({
</span>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-[var(--color-text-tertiary)]">
<span className="inline-flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]" aria-hidden>
{skill.source === 'clawhub' ? 'public' : 'language'}
</span>
<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)]">
{t(`market.source.${skill.source}`)}
</span>
{skill.author.handle && (
@ -85,7 +73,7 @@ export function SkillCard({
{skill.tags.slice(0, MAX_VISIBLE_TAGS).map((tag) => (
<span
key={tag}
className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[10px] text-[var(--color-text-tertiary)]"
className="rounded-full bg-[var(--color-surface-container)] px-2 py-0.5 text-[10px] text-[var(--color-text-secondary)]"
>
{tag}
</span>
@ -98,12 +86,15 @@ export function SkillCard({
</div>
)}
<div className="mt-auto flex flex-wrap items-center justify-between gap-x-2 gap-y-1.5 pt-3">
<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">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<SecurityBadge status={skill.securityStatus} />
<InstallStateBadge state={skill.installState} />
{/* The quick-install button already communicates "installable" — skip the badge when the button renders. */}
{!(skill.installState === 'installable' && showInstallButton) && (
<InstallStateBadge state={skill.installState} />
)}
</div>
<div className="ml-auto flex flex-shrink-0 items-center gap-2 text-[11px] text-[var(--color-text-tertiary)]">
<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>
{formatCount(skill.stats.downloads)}
@ -114,15 +105,15 @@ export function SkillCard({
{formatCount(skill.stats.stars)}
</span>
)}
{onInstall && skill.installState === 'installable' && (
{showInstallButton && (
<button
type="button"
disabled={installing}
onClick={(e) => {
e.stopPropagation()
onInstall(skill.id)
onInstall?.(skill.id)
}}
className="inline-flex min-h-7 items-center gap-1 rounded-lg bg-[var(--color-brand)] px-2.5 text-[11px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
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 ? (
<span className="h-3 w-3 animate-spin rounded-full border border-current border-t-transparent" aria-hidden />

View File

@ -2075,6 +2075,13 @@ export const en = {
'market.security.benign': 'Scanned safe',
'market.security.unknown': 'Not audited',
'market.security.flagged': 'Flagged',
'market.securityHint.verified': 'Published by a verified author and passed the sources security scan.',
'market.securityHint.benign': 'The sources security scan found no risks.',
'market.securityHint.unknown': 'No security audit data from the source — review the files before installing.',
'market.securityHint.flagged': 'The sources security scan flagged potential risks.',
'market.disclaimer.title': 'Use third-party skills with care.',
'market.disclaimer.body': 'Skills come from community sources and are not audited by this app. Review the files before installing — ideally have AI scan them for safety first.',
'market.disclaimer.dismiss': 'Dismiss disclaimer',
'market.installedFilter.all': 'All skills',
'market.installedFilter.installed': 'Installed',
'market.installedFilter.installable': 'Not installed',

View File

@ -2077,6 +2077,13 @@ export const jp: Record<TranslationKey, string> = {
'market.security.benign': 'スキャン済み・安全',
'market.security.unknown': '未監査',
'market.security.flagged': 'リスクあり',
'market.securityHint.verified': '認証済みの作者によって公開され、提供元のセキュリティスキャンに合格しています。',
'market.securityHint.benign': '提供元のセキュリティスキャンでリスクは検出されませんでした。',
'market.securityHint.unknown': '提供元からセキュリティ監査データが提供されていません。インストール前にファイルを確認してください。',
'market.securityHint.flagged': '提供元のセキュリティスキャンで潜在的なリスクが検出されました。',
'market.disclaimer.title': 'サードパーティ製スキルにご注意ください。',
'market.disclaimer.body': 'スキルはコミュニティの第三者ソースから提供されており、本アプリでは内容の安全性を監査していません。インストール前にファイルを確認し、まず AI にスキャンさせて安全を確認することをおすすめします。',
'market.disclaimer.dismiss': '免責事項を閉じる',
'market.installedFilter.all': 'すべてのスキル',
'market.installedFilter.installed': 'インストール済み',
'market.installedFilter.installable': '未インストール',

View File

@ -2077,6 +2077,13 @@ export const kr: Record<TranslationKey, string> = {
'market.security.benign': '스캔 완료·안전',
'market.security.unknown': '미감사',
'market.security.flagged': '위험 표시됨',
'market.securityHint.verified': '인증된 작성자가 게시했으며 소스의 보안 검사를 통과했습니다.',
'market.securityHint.benign': '소스의 보안 검사에서 위험이 발견되지 않았습니다.',
'market.securityHint.unknown': '소스에서 보안 감사 데이터를 제공하지 않습니다. 설치 전에 파일을 직접 확인하세요.',
'market.securityHint.flagged': '소스의 보안 검사에서 잠재적 위험이 표시되었습니다.',
'market.disclaimer.title': '서드파티 스킬은 신중히 사용하세요.',
'market.disclaimer.body': '스킬은 커뮤니티 서드파티 소스에서 제공되며 이 앱은 내용에 대한 보안 감사를 수행하지 않습니다. 설치 전에 파일을 확인하고, 먼저 AI로 스캔하여 안전을 확인한 뒤 사용하는 것을 권장합니다.',
'market.disclaimer.dismiss': '면책 조항 닫기',
'market.installedFilter.all': '모든 스킬',
'market.installedFilter.installed': '설치됨',
'market.installedFilter.installable': '미설치',

View File

@ -2077,6 +2077,13 @@ export const zh: Record<TranslationKey, string> = {
'market.security.benign': '掃描無風險',
'market.security.unknown': '未稽核',
'market.security.flagged': '存在風險',
'market.securityHint.verified': '發布者已認證,且通過來源方安全掃描。',
'market.securityHint.benign': '來源方安全掃描未發現風險。',
'market.securityHint.unknown': '來源方未提供安全審計資料,安裝前請自行檢查檔案內容。',
'market.securityHint.flagged': '來源方安全掃描標記該技能存在潛在風險。',
'market.disclaimer.title': '第三方技能,謹慎使用。',
'market.disclaimer.body': '技能來自社群第三方來源,本應用不對其內容做安全審計。安裝前請先查看技能檔案,建議先讓 AI 掃描一遍、確認安全後再使用。',
'market.disclaimer.dismiss': '關閉免責聲明',
'market.installedFilter.all': '全部技能',
'market.installedFilter.installed': '已安裝',
'market.installedFilter.installable': '未安裝',

View File

@ -2077,6 +2077,13 @@ export const zh: Record<TranslationKey, string> = {
'market.security.benign': '扫描无风险',
'market.security.unknown': '未审计',
'market.security.flagged': '存在风险',
'market.securityHint.verified': '发布者已认证,且通过来源方安全扫描。',
'market.securityHint.benign': '来源方安全扫描未发现风险。',
'market.securityHint.unknown': '来源方未提供安全审计数据,安装前请自行检查文件内容。',
'market.securityHint.flagged': '来源方安全扫描标记该技能存在潜在风险。',
'market.disclaimer.title': '第三方技能,谨慎使用。',
'market.disclaimer.body': '技能来自社区第三方来源,本应用不对其内容做安全审计。安装前请先查看技能文件,建议先让 AI 扫描一遍、确认安全后再使用。',
'market.disclaimer.dismiss': '关闭免责声明',
'market.installedFilter.all': '全部技能',
'market.installedFilter.installed': '已安装',
'market.installedFilter.installable': '未安装',