diff --git a/apps/desktop/src/main/__tests__/icon-governance-contract.test.ts b/apps/desktop/src/main/__tests__/icon-governance-contract.test.ts index c4aabd34880..f6c85f1b3c4 100644 --- a/apps/desktop/src/main/__tests__/icon-governance-contract.test.ts +++ b/apps/desktop/src/main/__tests__/icon-governance-contract.test.ts @@ -86,6 +86,25 @@ const STROKE_EXCEPTION_FILES = new Set([ resolve(REPO_ROOT, 'apps/desktop/src/renderer/settings/provider-brand-marks.tsx'), ]); +// Governance: inline hand-drawn `` literals are an anti-pattern — +// generic UI glyphs must ride the @maka/ui/icons lucide funnel so they share +// one stroke, one sizing seam, and one family. The ONLY files allowed to hold +// a raw `` are the vendored *brand* marks (each audited below); they carry +// official multi-color logo geometry that no generic icon can stand in for: +// - packages/ui/src/bot-brand-logo.tsx — Telegram/Feishu/WeCom/… channel +// brand logos (the icon-set-no-direct-lucide contract pins each provider +// to a local SVG component, so this file MUST keep inline SVG). +// - apps/desktop/src/renderer/settings/provider-brand-marks.tsx — the LLM +// provider brand marks (SiliconCloud, Ollama, xAI, …); every path here is +// byte-provenance-pinned above to an upstream brand package. +// packages/ui/src/icons.tsx is NOT listed: it is a pure lucide re-export with +// zero `` literals, so it needs no exemption. Any NEW raw `` under +// the two source trees must fail this suite and move to @maka/ui/icons. +const INLINE_SVG_ALLOWLIST = new Set([ + resolve(REPO_ROOT, 'packages/ui/src/bot-brand-logo.tsx'), + resolve(REPO_ROOT, 'apps/desktop/src/renderer/settings/provider-brand-marks.tsx'), +]); + async function walkTsx(dir: string): Promise { const out: string[] = []; const entries = await readdir(dir, { withFileTypes: true }); @@ -128,6 +147,46 @@ describe('icon + typography governance contract', () => { ); }); + it('bans inline hand-drawn in .tsx outside the brand-mark allowlist', async () => { + // Machine-walk both source trees the same way the repo-wide + // OverlayScrollbars ban walks its workspaces — any .tsx that isn't a + // vendored brand mark must route generic glyphs through @maka/ui/icons + // instead of hand-drawing an . + const dirs = [ + resolve(REPO_ROOT, 'apps/desktop/src/renderer'), + resolve(REPO_ROOT, 'packages/ui/src'), + ]; + const offenders: string[] = []; + const seenAllowlisted = new Set(); + for (const dir of dirs) { + for (const file of await walkTsx(dir)) { + const src = await readFile(file, 'utf8'); + const hasInlineSvg = /]/.test(src); + if (INLINE_SVG_ALLOWLIST.has(file)) { + if (hasInlineSvg) seenAllowlisted.add(file); + continue; + } + if (hasInlineSvg) offenders.push(rel(file)); + } + } + assert.deepEqual( + offenders, + [], + 'Inline hand-drawn is an anti-pattern — replace it with a semantically-correct ' + + 'icon from @maka/ui/icons (add the re-export to packages/ui/src/icons.tsx if missing). ' + + `Offending files:\n ${offenders.join('\n ')}`, + ); + // Guard the allowlist against rot: if a listed brand-mark file no longer + // holds inline SVG, drop it from INLINE_SVG_ALLOWLIST rather than leaving a + // stale exemption that would silently re-permit a future offender. + for (const file of INLINE_SVG_ALLOWLIST) { + assert.ok( + seenAllowlisted.has(file), + `${rel(file)} is allowlisted for inline SVG but no longer contains any — remove it from INLINE_SVG_ALLOWLIST`, + ); + } + }); + it('session-sidebar-nav imports exactly the decided semantic icon set from ./icons.js', async () => { const src = await readFile(SIDEBAR_NAV_FILE, 'utf8'); const importMatch = src.match(/import\s*\{([^}]*)\}\s*from\s*'\.\/icons\.js'/); diff --git a/apps/desktop/src/main/__tests__/visual-smoke-fixture.test.ts b/apps/desktop/src/main/__tests__/visual-smoke-fixture.test.ts index d212681be07..1b6826b81fb 100644 --- a/apps/desktop/src/main/__tests__/visual-smoke-fixture.test.ts +++ b/apps/desktop/src/main/__tests__/visual-smoke-fixture.test.ts @@ -627,20 +627,53 @@ describe('visual smoke fixture mode', () => { } }); - it('module fixtures open Skills and Daily Review in the app module surface', async () => { + it('module fixtures open Skills, MCP and Daily Review in the app module surface', async () => { const skills = resolveVisualSmokeFixture('module-skills', false); + const mcp = resolveVisualSmokeFixture('module-mcp', false); const dailyReview = resolveVisualSmokeFixture('module-daily-review', false); assert.ok(skills); + assert.ok(mcp); assert.ok(dailyReview); assert.equal(getVisualSmokeState(skills)?.sidebarSection, 'skills'); assert.equal(getVisualSmokeState(skills)?.sidebarCollapsed, false); assert.equal(getVisualSmokeState(skills)?.activeSessionId, 'visual-smoke-turn'); + assert.equal(getVisualSmokeState(mcp)?.sidebarSection, 'mcp'); + assert.equal(getVisualSmokeState(mcp)?.sidebarCollapsed, false); + assert.equal(getVisualSmokeState(mcp)?.activeSessionId, 'visual-smoke-turn'); assert.equal(getVisualSmokeState(dailyReview)?.sidebarSection, 'daily-review'); assert.equal(getVisualSmokeState(dailyReview)?.sidebarCollapsed, false); assert.equal(getVisualSmokeState(dailyReview)?.activeSessionId, 'visual-smoke-turn'); }); + it('module-mcp seeds a couple of installed servers so the 已安装 list renders', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-visual-smoke-mcp-')); + try { + const fixture = resolveVisualSmokeFixture('module-mcp', false); + assert.ok(fixture); + await seedVisualSmokeFixture({ + workspaceRoot, + fixture, + credentialStore: fakeCredentialStore(), + now: 1_700_000_000_000, + }); + const config = JSON.parse(await readFile(join(workspaceRoot, 'mcp.json'), 'utf8')) as { + version: number; + mcpServers: Record; + }; + assert.equal(config.version, 1); + const ids = Object.keys(config.mcpServers); + assert.ok(ids.length >= 2, 'installed list needs >=2 servers to render meaningfully'); + // enabled:false keeps visual-smoke deterministic — no real npx / HTTP + // connection is attempted, so the rows settle in the neutral 已停用 state. + for (const id of ids) { + assert.equal(config.mcpServers[id].enabled, false, `${id} stays disabled in the fixture`); + } + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); + it('module-skills seeds a managed-source market catalog (>=6 entries with categories) plus workspace skills', async () => { const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-visual-smoke-skills-')); const previousSourcesRoot = process.env.MAKA_SKILL_SOURCES_ROOT; diff --git a/apps/desktop/src/main/visual-smoke-fixture.ts b/apps/desktop/src/main/visual-smoke-fixture.ts index 904ac02a77b..5ce100bd0a3 100644 --- a/apps/desktop/src/main/visual-smoke-fixture.ts +++ b/apps/desktop/src/main/visual-smoke-fixture.ts @@ -63,6 +63,11 @@ const VISUAL_SMOKE_SCENARIOS = new Set([ 'settings-usage', 'settings-health', 'module-skills', + // MCP module page (SVG-governance + polish campaign): opens the 扩展 → MCP + // surface with a seeded mcp.json so the market grid, tab row, hero banner, + // and the installed server list all render for the alignment auditor + CDP + // capture in light / dark. + 'module-mcp', 'module-daily-review', // PR109b: workstation-statuses — seed one session per SessionStatus // (running / waiting_for_user / blocked × 4 reasons / active / review @@ -418,6 +423,10 @@ export function getVisualSmokeState(fixture: VisualSmokeFixture | null): VisualS return { ...state, activeSessionId: TURN_SESSION_ID, openSettingsSection: 'health' }; case 'module-skills': return { ...state, activeSessionId: TURN_SESSION_ID, sidebarSection: 'skills', sidebarCollapsed: false }; + case 'module-mcp': + // Open the 扩展 → MCP module directly so the CDP baseline captures the + // real market grid, tab row, hero banner, and installed server list. + return { ...state, activeSessionId: TURN_SESSION_ID, sidebarSection: 'mcp', sidebarCollapsed: false }; case 'module-daily-review': return { ...state, activeSessionId: TURN_SESSION_ID, sidebarSection: 'daily-review', sidebarCollapsed: false }; case 'workstation-statuses': @@ -603,6 +612,37 @@ export async function seedVisualSmokeFixture(input: { if (input.fixture.scenario === 'module-skills') { await seedSkillsMarketFixture(input.workspaceRoot); } + if (input.fixture.scenario === 'module-mcp') { + await seedMcpFixture(input.workspaceRoot); + } +} + +/** + * MCP module fixture: seeds an mcp.json with a couple of installed servers so + * the 已安装 tab and the server rows render for the alignment auditor + CDP + * capture. Both are `enabled: false` so no real `npx` / HTTP connection is + * attempted in visual-smoke mode — the rows render deterministically in the + * neutral 已停用 state (exception-only status: no color unless a real failure). + * The 市场 tab is the default surface and is driven by the static MCP_CATALOG, + * so it renders without any on-disk seed. + */ +async function seedMcpFixture(workspaceRoot: string): Promise { + const config = { + version: 1, + mcpServers: { + filesystem: { + enabled: false, + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/workspace/maka'], + }, + 'linear-remote': { + enabled: false, + url: 'https://mcp.linear.app/sse', + transport: 'sse', + }, + }, + }; + await writeJson(join(workspaceRoot, 'mcp.json'), config); } /** diff --git a/apps/desktop/src/renderer/mcp-page.tsx b/apps/desktop/src/renderer/mcp-page.tsx index 3c826b378a5..d8543db891a 100644 --- a/apps/desktop/src/renderer/mcp-page.tsx +++ b/apps/desktop/src/renderer/mcp-page.tsx @@ -7,11 +7,17 @@ import { DialogContent, DialogHeader, DialogRoot, + EmptyState, Input, InputGroup, InputGroupAddon, InputGroupInput, + PageHeader, SettingsSwitch as Switch, + TabsList, + TabsPanel, + TabsRoot, + TabsTrigger, Textarea, useMountedRef, useToast, @@ -252,21 +258,23 @@ export function McpPage() { return (
-
-
-

MCP

-

连接外部应用、数据与服务,为 Maka 安全地扩展新工具。

-
-
- - - -
- + + + + + + } + />
@@ -275,33 +283,26 @@ export function McpPage() { 从精选模板开始,或添加任意 stdio、Streamable HTTP 与 SSE server。
-
-
- - + setActiveTab(value as 'market' | 'installed')}> +
+ + 市场 {MCP_CATALOG.length} + 已安装 {entries.length} + + + + setQuery(event.currentTarget.value)} placeholder="搜索 MCP…" aria-label="搜索 MCP" /> +
- - - setQuery(event.currentTarget.value)} placeholder="搜索 MCP…" aria-label="搜索 MCP" /> - -
-
- {busy === 'load' ? ( -
正在读取 MCP 配置…
- ) : activeTab === 'market' ? ( - marketEntries.length > 0 ? ( + + {marketEntries.length > 0 ? (
{marketEntries.map((entry) => ( ))}
- ) : setQuery('')} /> - ) : entries.length === 0 ? ( -
-
- ) : installedEntries.length > 0 ? ( -
    - {installedEntries.map(([serverId, server]) => ( - void toggle(serverId, server, enabled)} - onEdit={() => openEdit(serverId, server)} - onTest={() => void testServer(serverId)} - onRemove={() => void remove(serverId)} - /> - ))} -
- ) : setQuery('')} />} -
+ ) : ( + setQuery('') }} + extraClassName="maka-mcp-empty" + /> + )} + + + + {busy === 'load' ? ( +
正在读取 MCP 配置…
+ ) : entries.length === 0 ? ( + setActiveTab('market') }} + extraClassName="maka-mcp-empty" + /> + ) : installedEntries.length > 0 ? ( +
    + {installedEntries.map(([serverId, server]) => ( + void toggle(serverId, server, enabled)} + onEdit={() => openEdit(serverId, server)} + onTest={() => void testServer(serverId)} + onRemove={() => void remove(serverId)} + /> + ))} +
+ ) : ( + setQuery('') }} + extraClassName="maka-mcp-empty" + /> + )} +
+
{editor && ( @@ -373,7 +397,7 @@ function McpCatalogCard(props: { return (
{props.entry.name} @@ -408,33 +432,6 @@ function McpCatalogCard(props: { ); } -function McpBrandMark({ entry }: { entry: McpCatalogEntry }) { - if (entry.id === 'slack') return ( - - ); - if (entry.id === 'line') return ( - - ); - if (entry.id === 'google-calendar') return ( - - ); - if (entry.id === 'figma') return ( - - ); - if (entry.id === 'vercel') return ; - if (entry.id === 'supabase') return ( - - ); - return {entry.mark}; -} - function McpServerRow(props: { serverId: string; server: McpServerConfig; @@ -451,9 +448,17 @@ function McpServerRow(props: { return (
  • -
    - ); -} - function McpEditorDialog(props: { state: Exclude; saving: boolean; @@ -623,12 +617,15 @@ function delay(ms: number): Promise { return new Promise((resolve) => window.setTimeout(resolve, ms)); } -function presentStatus(status: McpServerStatus | undefined, enabled: boolean): { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'destructive' } { - if (!enabled || status?.state === 'disabled') return { label: '已停用', tone: 'neutral' }; - if (!status || status.state === 'disconnected') return { label: '未连接', tone: 'neutral' }; - if (status.state === 'connecting') return { label: '连接中', tone: 'info' }; - if (status.state === 'connected') return { label: `${status.toolCount} 个工具`, tone: 'success' }; - return { label: '连接失败', tone: 'destructive' }; +// `exception` marks the states that earn a toned Chip + colored status dot +// (status-color restraint #651). 已停用 / 未连接 / 连接中 / 已连接 are all +// expected states and stay neutral; only 连接失败 raises the destructive tone. +function presentStatus(status: McpServerStatus | undefined, enabled: boolean): { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'destructive'; exception: boolean } { + if (!enabled || status?.state === 'disabled') return { label: '已停用', tone: 'neutral', exception: false }; + if (!status || status.state === 'disconnected') return { label: '未连接', tone: 'neutral', exception: false }; + if (status.state === 'connecting') return { label: '连接中', tone: 'info', exception: false }; + if (status.state === 'connected') return { label: `${status.toolCount} 个工具`, tone: 'success', exception: false }; + return { label: '连接失败', tone: 'destructive', exception: true }; } function exampleJson(): string { diff --git a/apps/desktop/src/renderer/styles/module-pages/mcp.css b/apps/desktop/src/renderer/styles/module-pages/mcp.css index 73ad9ba6ce6..bebbd56541a 100644 --- a/apps/desktop/src/renderer/styles/module-pages/mcp.css +++ b/apps/desktop/src/renderer/styles/module-pages/mcp.css @@ -3,10 +3,6 @@ align-content: start; } -.maka-mcp-header { - align-items: center; -} - .maka-mcp-workspace { width: min(100%, 900px); min-height: 0; @@ -51,29 +47,44 @@ } .maka-mcp-hero-signal { - min-width: 240px; display: flex; align-items: center; justify-content: flex-end; - color: var(--foreground-secondary); + gap: var(--space-3); } -.maka-mcp-hero-signal svg { - width: 38px; - height: 38px; - padding: var(--space-2); +/* Labeled pictogram cluster — the same "framed glyph + caption" tiles the + skills featured banner uses, so the two extension pages read as siblings. + Icons ride the @maka/ui library (Task A); no inline SVG here. */ +.maka-mcp-hero-signal span { + width: 76px; + display: inline-flex; + flex-direction: column; + align-items: center; + gap: var(--space-1-5); + padding: var(--space-3) var(--space-2); border: var(--border-width-hairline) solid var(--border); border-radius: var(--radius-control); background: var(--background); + color: var(--foreground-secondary); } -.maka-mcp-hero-signal span { - width: 24px; - height: 1px; - background: var(--border); +.maka-mcp-hero-signal svg { + width: 22px; + height: 22px; +} + +.maka-mcp-hero-signal small { + color: var(--muted-foreground); + font-size: var(--font-size-caption); + font-weight: var(--font-weight-medium); + line-height: var(--leading-tight); } -.maka-mcp-controls { +/* Tab row rhythm mirrors the skills page (.maka-skill-tabs-bar): an + underline TabsList on the left, the search field pinned to the right, + so the two extension pages read as siblings. */ +.maka-mcp-tabs-bar { display: flex; align-items: center; justify-content: space-between; @@ -83,46 +94,26 @@ .maka-mcp-tabs { display: flex; - align-items: center; gap: var(--space-4); + border-bottom: 0; } -.maka-mcp-tabs button { +.maka-mcp-tab { position: relative; - min-height: 36px; + height: 32px; display: inline-flex; align-items: center; gap: var(--space-1); padding: 0 1px; border: 0; + border-radius: 0; background: transparent; color: var(--muted-foreground); - font: inherit; font-size: var(--font-size-ui); font-weight: var(--font-weight-medium); } -.maka-mcp-tabs button::after { - position: absolute; - right: 0; - bottom: -1px; - left: 0; - height: 2px; - border-radius: var(--radius-pill); - background: transparent; - content: ''; -} - -.maka-mcp-tabs button[data-active="true"] { - color: var(--foreground); - font-weight: var(--font-weight-semibold); -} - -.maka-mcp-tabs button[data-active="true"]::after { - background: var(--foreground); -} - -.maka-mcp-tabs button span { +.maka-mcp-tab span { min-width: 16px; height: 16px; display: inline-flex; @@ -130,15 +121,18 @@ justify-content: center; padding-inline: var(--space-1); border-radius: var(--radius-pill); - background: var(--state-hover-bg); + background: oklch(from var(--foreground) l c h / 0.06); + color: var(--muted-foreground); font-size: var(--font-size-caption); + font-weight: var(--font-weight-semibold); font-variant-numeric: tabular-nums; } .maka-mcp-search { width: min(300px, 38vw); - height: var(--h-control-md); + height: var(--h-control-sm); margin-bottom: var(--space-2); + flex: 0 0 auto; } .maka-mcp-tab-panel { @@ -190,16 +184,6 @@ letter-spacing: var(--tracking-normal); } -.maka-mcp-market-icon svg { - width: 27px; - height: 27px; -} - -.maka-mcp-market-icon[data-brand="figma"] svg { - width: 18px; - height: 28px; -} - .maka-mcp-market-icon[data-brand="line"] span { font-size: var(--font-size-caption); } @@ -369,10 +353,16 @@ background: var(--muted-foreground); } -.maka-mcp-status-dot[data-tone="success"] { background: var(--success); } -.maka-mcp-status-dot[data-tone="info"] { background: var(--info); } +/* Status-color restraint (#651): the dot stays neutral for every expected + state; only a genuine failure (data-tone="destructive") raises color. */ .maka-mcp-status-dot[data-tone="destructive"] { background: var(--destructive); } +.maka-mcp-server-state { + color: var(--muted-foreground); + font-size: var(--font-size-caption); + font-variant-numeric: tabular-nums; +} + .maka-mcp-server-identity { min-width: 0; display: grid; @@ -461,36 +451,22 @@ white-space: pre-wrap; } -.maka-mcp-empty, +/* The empty / no-results panels ride the shared EmptyState primitive; this + class is only a light layout override on top of `.maka-empty-state`. */ +.maka-mcp-empty { + align-self: start; +} + .maka-mcp-loading { min-height: 240px; display: grid; place-items: center; align-content: center; - gap: var(--space-2); padding: var(--space-8); color: var(--muted-foreground); text-align: center; } -.maka-mcp-empty > svg { - width: 32px; - height: 32px; - margin-bottom: var(--space-2); - color: var(--foreground-secondary); -} - -.maka-mcp-empty strong { - color: var(--foreground); - font-size: var(--font-size-heading); -} - -.maka-mcp-empty span { - max-width: 52ch; - font-size: var(--font-size-ui); - line-height: var(--leading-normal); -} - .maka-mcp-editor-dialog { width: min(92vw, var(--maka-chat-measure)); grid-template-rows: auto auto minmax(0, 1fr); @@ -622,17 +598,13 @@ } @media (max-width: 760px) { - .maka-mcp-header, - .maka-mcp-controls { - align-items: stretch; - } - .maka-mcp-hero-signal { display: none; } - .maka-mcp-controls { - display: grid; + .maka-mcp-tabs-bar { + flex-wrap: wrap; + align-items: stretch; } .maka-mcp-search { diff --git a/packages/core/src/visual-smoke.ts b/packages/core/src/visual-smoke.ts index daf24349773..3b857047711 100644 --- a/packages/core/src/visual-smoke.ts +++ b/packages/core/src/visual-smoke.ts @@ -50,6 +50,7 @@ export type VisualSmokeScenario = | 'settings-usage' | 'settings-health' | 'module-skills' + | 'module-mcp' | 'module-daily-review' | 'workstation-statuses' | 'plan-reminders' diff --git a/packages/ui/src/primitives/menu.tsx b/packages/ui/src/primitives/menu.tsx index f9c4fee9895..4267b4a2752 100644 --- a/packages/ui/src/primitives/menu.tsx +++ b/packages/ui/src/primitives/menu.tsx @@ -2,7 +2,7 @@ import { Menu as MenuPrimitive } from "@base-ui/react/menu"; import { cn } from "../utils.js"; -import { ChevronRightIcon } from "../icons.js"; +import { Check, ChevronRightIcon } from "../icons.js"; import type * as React from "react"; export const MenuCreateHandle: typeof MenuPrimitive.createHandle = @@ -163,20 +163,7 @@ export function MenuCheckboxItem({ ) : ( <> - + {children} @@ -206,20 +193,7 @@ export function MenuRadioItem({ {...props} > - + {children} diff --git a/scripts/audit-alignment.mjs b/scripts/audit-alignment.mjs index babe9dec303..b20a1f043b1 100644 --- a/scripts/audit-alignment.mjs +++ b/scripts/audit-alignment.mjs @@ -17,6 +17,7 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const DESKTOP_DIR = join(ROOT, 'apps', 'desktop'); const FIXTURES = [ 'module-skills', + 'module-mcp', 'module-daily-review', 'plan-reminders', 'settings-general',