From 55ba2392c10e8be8b5bb936ec42629d8d7d4a53e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 9 Jul 2026 15:07:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20=E6=8A=80=E8=83=BD=E5=B8=82?= =?UTF-8?q?=E5=9C=BA=E5=A2=9E=E5=8A=A0=E5=85=8D=E8=B4=A3=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E5=8D=A1=E7=89=87=E4=B8=8E=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 市场顶部新增可关闭的第三方技能免责声明,提示安装前先用 AI 扫描 - 图标缺失时改用按技能名生成的确定性彩色首字母头像 - 卡片视觉优化:隐藏冗余的"可安装"徽章、底部分隔线、hover 反馈 - 安全徽章增加 tooltip 解释各状态含义(五语言) - 修复暗色主题下筛选器激活态对比度 1.3:1 不可读的问题, 统一改用 brand-on-neutral 组合使三主题均达 WCAG AA --- desktop/src/components/market/FilterBar.tsx | 2 +- .../components/market/InstallStateBadge.tsx | 3 +- .../market/MarketDisclaimer.test.tsx | 40 +++++++++++++ .../components/market/MarketDisclaimer.tsx | 57 ++++++++++++++++++ desktop/src/components/market/MarketHome.tsx | 4 +- .../src/components/market/SecurityBadge.tsx | 4 +- desktop/src/components/market/SkillAvatar.tsx | 59 +++++++++++++++++++ .../src/components/market/SkillCard.test.tsx | 39 ++++++++++++ desktop/src/components/market/SkillCard.tsx | 43 ++++++-------- desktop/src/i18n/locales/en.ts | 7 +++ desktop/src/i18n/locales/jp.ts | 7 +++ desktop/src/i18n/locales/kr.ts | 7 +++ desktop/src/i18n/locales/zh-TW.ts | 7 +++ desktop/src/i18n/locales/zh.ts | 7 +++ 14 files changed, 256 insertions(+), 30 deletions(-) create mode 100644 desktop/src/components/market/MarketDisclaimer.test.tsx create mode 100644 desktop/src/components/market/MarketDisclaimer.tsx create mode 100644 desktop/src/components/market/SkillAvatar.tsx diff --git a/desktop/src/components/market/FilterBar.tsx b/desktop/src/components/market/FilterBar.tsx index d58c1158..bcc91fed 100644 --- a/desktop/src/components/market/FilterBar.tsx +++ b/desktop/src/components/market/FilterBar.tsx @@ -12,7 +12,7 @@ function FilterTrigger({ label, value, active }: { label: string; value: string; diff --git a/desktop/src/components/market/InstallStateBadge.tsx b/desktop/src/components/market/InstallStateBadge.tsx index 625be572..7be929db 100644 --- a/desktop/src/components/market/InstallStateBadge.tsx +++ b/desktop/src/components/market/InstallStateBadge.tsx @@ -8,7 +8,8 @@ const STYLES: Record = { }, 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', diff --git a/desktop/src/components/market/MarketDisclaimer.test.tsx b/desktop/src/components/market/MarketDisclaimer.test.tsx new file mode 100644 index 00000000..ca6e9e3a --- /dev/null +++ b/desktop/src/components/market/MarketDisclaimer.test.tsx @@ -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() + + 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() + + 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() + + expect(screen.queryByTestId('market-disclaimer')).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/market/MarketDisclaimer.tsx b/desktop/src/components/market/MarketDisclaimer.tsx new file mode 100644 index 00000000..ab47830a --- /dev/null +++ b/desktop/src/components/market/MarketDisclaimer.tsx @@ -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 ( +
+ + gpp_maybe + +

+ {t('market.disclaimer.title')}{' '} + {t('market.disclaimer.body')} +

+ +
+ ) +} diff --git a/desktop/src/components/market/MarketHome.tsx b/desktop/src/components/market/MarketHome.tsx index 783ed3f1..0dbeb9c0 100644 --- a/desktop/src/components/market/MarketHome.tsx +++ b/desktop/src/components/market/MarketHome.tsx @@ -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 (
-
+
+
diff --git a/desktop/src/components/market/SecurityBadge.tsx b/desktop/src/components/market/SecurityBadge.tsx index 398f549b..a40128f4 100644 --- a/desktop/src/components/market/SecurityBadge.tsx +++ b/desktop/src/components/market/SecurityBadge.tsx @@ -12,7 +12,8 @@ const STYLES: Record = { }, 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 ( diff --git a/desktop/src/components/market/SkillAvatar.tsx b/desktop/src/components/market/SkillAvatar.tsx new file mode 100644 index 00000000..868647b0 --- /dev/null +++ b/desktop/src/components/market/SkillAvatar.tsx @@ -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 + size?: number + className?: string +}) { + if (skill.iconUrl) { + return ( + + ) + } + const hue = hashHue(skill.name) + return ( + + {initialOf(skill.name)} + + ) +} diff --git a/desktop/src/components/market/SkillCard.test.tsx b/desktop/src/components/market/SkillCard.test.tsx index 3aa50d90..0422e8c4 100644 --- a/desktop/src/components/market/SkillCard.test.tsx +++ b/desktop/src/components/market/SkillCard.test.tsx @@ -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() + + expect(screen.getByText('Install')).toBeInTheDocument() + expect(screen.queryByTestId('install-badge-installable')).not.toBeInTheDocument() + }) + + it('renders a deterministic letter avatar when iconUrl is missing', () => { + render() + + 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() + 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( + , + ) + + 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() + + expect(screen.getByTestId('security-badge-unknown')).toHaveAttribute( + 'title', + 'No security audit data from the source — review the files before installing.', + ) + }) }) diff --git a/desktop/src/components/market/SkillCard.tsx b/desktop/src/components/market/SkillCard.tsx index d43ffffc..2aeab413 100644 --- a/desktop/src/components/market/SkillCard.tsx +++ b/desktop/src/components/market/SkillCard.tsx @@ -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 (
onOpen(skill.id)} @@ -41,18 +43,7 @@ export function SkillCard({ }} >
- {skill.iconUrl ? ( - - ) : ( - - auto_awesome - - )} +
{skill.name} @@ -62,11 +53,8 @@ export function SkillCard({ )}
-
- - - {skill.source === 'clawhub' ? 'public' : 'language'} - +
+ {t(`market.source.${skill.source}`)} {skill.author.handle && ( @@ -85,7 +73,7 @@ export function SkillCard({ {skill.tags.slice(0, MAX_VISIBLE_TAGS).map((tag) => ( {tag} @@ -98,12 +86,15 @@ export function SkillCard({
)} -
+
- + {/* The quick-install button already communicates "installable" — skip the badge when the button renders. */} + {!(skill.installState === 'installable' && showInstallButton) && ( + + )}
-
+
download {formatCount(skill.stats.downloads)} @@ -114,15 +105,15 @@ export function SkillCard({ {formatCount(skill.stats.stars)} )} - {onInstall && skill.installState === 'installable' && ( + {showInstallButton && (