diff --git a/comparisons/baselines/compare-sp4-badge.svg b/comparisons/baselines/compare-sp4-badge.svg new file mode 100644 index 0000000..7e61249 --- /dev/null +++ b/comparisons/baselines/compare-sp4-badge.svg @@ -0,0 +1 @@ +stars42.3k \ No newline at end of file diff --git a/comparisons/baselines/compare-sp5-github-row.svg b/comparisons/baselines/compare-sp5-github-row.svg new file mode 100644 index 0000000..922040f --- /dev/null +++ b/comparisons/baselines/compare-sp5-github-row.svg @@ -0,0 +1 @@ +stars124.3kforks26.8kissues1.3kreleasev15.0.3licenseMIT \ No newline at end of file diff --git a/comparisons/compare-sp4-badge.png b/comparisons/compare-sp4-badge.png new file mode 100644 index 0000000..dfecad7 Binary files /dev/null and b/comparisons/compare-sp4-badge.png differ diff --git a/comparisons/compare-sp5-github-row.png b/comparisons/compare-sp5-github-row.png new file mode 100644 index 0000000..9c14cd3 Binary files /dev/null and b/comparisons/compare-sp5-github-row.png differ diff --git a/docs/superpowers/plans/2026-05-28-github-badge-rendering.md b/docs/superpowers/plans/2026-05-28-github-badge-rendering.md new file mode 100644 index 0000000..83ee3b5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-github-badge-rendering.md @@ -0,0 +1,1297 @@ +# GitHub Badge Rendering Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a hand-drawn GitHub-style `Badge` library component plus three MCP tools (`render-badge`, `render-github-badge`, `render-github-badge-row`) so an agent can render literal or live-data badges for any GitHub repo. + +**Architecture:** Pure library component (intrinsic SVG, no network, brand-aware via `resolveBrand(props.brand)` in body — matches the documented `CLAUDE.md` pattern). All HTTP lives in `mcp/src/githubClient.ts` behind an injectable `fetch` with TTL cache + in-flight promise dedup + optional `GITHUB_TOKEN`. MCP tools import the rendering surface from `goldenchart/server` so emitted SVGs are self-contained. + +**Tech Stack:** React 19, Rough.js, D3 (existing), Zod schemas, Vitest snapshots. No new runtime deps. Node 18+ `globalThis.fetch`. + +**Spec:** `docs/superpowers/specs/2026-05-28-github-badge-rendering-design.md` +**Branch:** `spec/github-badge-rendering` (already exists, spec already committed). + +--- + +## File structure + +**Library (`src/`):** +- Create `src/core/badgeIcons.ts` — `BadgeIcon` type, icon path table, fixed tone color table. Pure data + types. +- Create `src/components/Badge.tsx` — the component. +- Create `src/components/Badge.test.ts` — vitest tests: width formula, brand wiring, tone selection, optional-icon branch, server SVG snapshot for one case. +- Modify `src/components/index.ts` — export `Badge`, `BadgeProps`, `BadgeTone`, `BadgeIcon`. +- Modify `src/index.ts` — re-export the same names. + +**MCP (`mcp/src/`):** +- Create `mcp/src/githubClient.ts` — `createGithubClient`, types, errors. No I/O at import time. +- Create `mcp/src/githubClient.test.ts` — TTL cache, in-flight dedup, error mapping, auth header behavior. All via the injected `fetch` stub. +- Create `mcp/src/badgeTools.ts` — three `ToolDef`s. Does NOT go through `makeRenderTool` (that factory assumes `width`/`height` args; the badge tools don't have those). +- Create `mcp/src/badgeTools.test.ts` — snapshot tests for all three tools, fetch stubbed. +- Modify `mcp/src/tools.ts` — import `badgeTools` and add to the exported tool list. +- New snapshot files appear under `mcp/src/__snapshots__/badgeTools.test.ts.snap`. + +**Carry-forward render:** +- Modify `mcp/compare/...` (whatever script `npm run compare` already runs — locate before editing) to add one literal `Badge` and one `render-github-badge-row` example with a stubbed client. + +--- + +## Task 1: Icon + tone color tables + +**Files:** +- Create: `src/core/badgeIcons.ts` +- Create: `src/core/badgeIcons.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// src/core/badgeIcons.test.ts +import { describe, it, expect } from 'vitest'; +import { BADGE_ICON_PATHS, BADGE_TONE_COLORS, BADGE_ICONS, BADGE_TONES } from './badgeIcons'; + +describe('badgeIcons', () => { + it('exposes a stroke path string for every icon name', () => { + for (const name of BADGE_ICONS) { + const entry = BADGE_ICON_PATHS[name]; + const strokes = Array.isArray(entry) ? entry : [entry]; + expect(strokes.length).toBeGreaterThan(0); + for (const d of strokes) { + // Stroke-only contract: no fills (no `Z`), nothing closes the path. + expect(d).not.toMatch(/[Zz]/); + expect(d.length).toBeGreaterThan(0); + } + } + }); + it('has a color for every fixed tone (success/warn/danger)', () => { + expect(BADGE_TONE_COLORS.success).toMatch(/^#/); + expect(BADGE_TONE_COLORS.warn).toMatch(/^#/); + expect(BADGE_TONE_COLORS.danger).toMatch(/^#/); + }); + it('lists every supported tone literal', () => { + expect(new Set(BADGE_TONES)).toEqual( + new Set(['neutral', 'info', 'success', 'warn', 'danger']), + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run from project root: `npm test -- src/core/badgeIcons` +Expected: FAIL with `cannot find module './badgeIcons'`. + +- [ ] **Step 3: Implement `src/core/badgeIcons.ts`** + +```ts +/** + * Icon stroke paths and fixed tone colors for `Badge`. Pure data, no React. + * + * Icon authoring contract (per spec): + * - viewBox 16x16 + * - stroke-only (no `Z`, no fills) + * - single open sub-path preferred; if a glyph genuinely needs two strokes, + * the entry may be `string[]` and the component renders each as its own + * Rough path. + */ + +export const BADGE_TONES = ['neutral', 'info', 'success', 'warn', 'danger'] as const; +export type BadgeTone = (typeof BADGE_TONES)[number]; + +export const BADGE_ICONS = [ + 'star', + 'fork', + 'issue', + 'tag', + 'commit', + 'license', + 'lang', + 'check', +] as const; +export type BadgeIcon = (typeof BADGE_ICONS)[number]; + +/** Stroke-only path data, authored against a 16x16 box. */ +export const BADGE_ICON_PATHS: Record = { + // Five-point star outline (open at the top tip so it remains an open stroke). + star: 'M8 1 L10 6 L15 6 L11 9.5 L12.5 14.5 L8 11.5 L3.5 14.5 L5 9.5 L1 6 L6 6', + // Two circles + a connector (git fork glyph). + fork: [ + 'M4 3 a2 2 0 1 0 0 4 a2 2 0 1 0 0 -4', + 'M12 3 a2 2 0 1 0 0 4 a2 2 0 1 0 0 -4', + 'M4 7 L4 11 a2 2 0 0 0 2 2 L10 13', + 'M12 7 L12 9', + ], + // Circle with vertical bar (open issue indicator). + issue: ['M8 1 a7 7 0 1 0 0 14 a7 7 0 1 0 0 -14', 'M8 5 L8 9', 'M8 11 L8 12'], + // Price-tag silhouette (open stroke; no Z). + tag: 'M1 8 L8 1 L15 1 L15 8 L8 15', + // Git commit dot + line. + commit: ['M2 8 L6 8', 'M10 8 L14 8', 'M8 6 a2 2 0 1 0 0 4 a2 2 0 1 0 0 -4'], + // Scroll silhouette (open). + license: ['M3 2 L13 2 L13 13 L8 13', 'M3 2 L3 13 L8 13 L8 11', 'M5 5 L11 5', 'M5 8 L11 8'], + // Three vertical bars (language stack). + lang: ['M3 13 L3 5', 'M8 13 L8 3', 'M13 13 L13 7'], + // Check mark. + check: 'M2 9 L6 13 L14 3', +}; + +export const BADGE_TONE_COLORS: Record<'success' | 'warn' | 'danger', string> = { + success: '#3a8a3a', + warn: '#b8860b', + danger: '#b13a3a', +}; +``` + +- [ ] **Step 4: Run tests** + +Run: `npm test -- src/core/badgeIcons` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/badgeIcons.ts src/core/badgeIcons.test.ts +git commit -m "feat(badge): icon stroke paths + fixed tone colors" +``` + +--- + +## Task 2: `Badge` component + +**Files:** +- Create: `src/components/Badge.tsx` +- Create: `src/components/Badge.test.ts` + +The badge does NOT use ``, does NOT read brand from context, does NOT register an a11y title (callers can wrap if needed). It calls `resolveBrand(props.brand)` and `resolveVibe(props.vibe)` directly in the body. Width is computed from text measurement via `src/core/text.ts` (the same helper axis ticks use — locate the export before relying on it; if the helper signature is `measureTextWidth(text, fontSize, fontFamily)`, use it; if it returns an approximation, that's fine). + +- [ ] **Step 1: Write the failing test** + +```ts +// src/components/Badge.test.ts +import { describe, it, expect } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { createElement } from 'react'; +import { Badge } from './Badge'; + +function render(props: Parameters[0]) { + return renderToStaticMarkup(createElement(Badge, props)); +} + +describe('Badge', () => { + it('renders an intrinsic with measurable width and constant height 26', () => { + const svg = render({ label: 'stars', value: '42.3k' }); + expect(svg).toMatch(/]+width="\d+"/); + expect(svg).toMatch(/]+height="26"/); + expect(svg).toContain('stars'); + expect(svg).toContain('42.3k'); + }); + it('renders the icon glyph when `icon` is set', () => { + const without = render({ label: 'stars', value: '0' }); + const withIcon = render({ label: 'stars', value: '0', icon: 'star' }); + expect(withIcon.length).toBeGreaterThan(without.length); + }); + it('uses success color for tone="success" and danger for tone="danger"', () => { + const ok = render({ label: 'build', value: 'passing', tone: 'success' }); + const bad = render({ label: 'build', value: 'failing', tone: 'danger' }); + expect(ok).toContain('#3a8a3a'); + expect(bad).toContain('#b13a3a'); + }); + it('uses brand palette[0] for tone="neutral"', () => { + const svg = render({ + label: 'x', value: 'y', tone: 'neutral', + brand: { palette: ['#123456', '#abcdef'] }, + }); + expect(svg).toContain('#123456'); + }); + it('produces a stable snapshot for a known input', () => { + const svg = render({ + label: 'stars', value: '42.3k', tone: 'info', icon: 'star', + brand: { palette: ['#222', '#0077cc'], font: 'sans-serif' }, + seed: 1, + }); + expect(svg).toMatchSnapshot(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- src/components/Badge` +Expected: FAIL with `cannot find module './Badge'`. + +- [ ] **Step 3: Implement `src/components/Badge.tsx`** + +Sketch (the implementer fills in the exact Rough.js calls based on existing primitives — see `src/primitives/RoughPath.tsx` and `src/primitives/RoughRectangle.tsx` for the established pattern): + +```tsx +import { createElement } from 'react'; +import { resolveBrand } from '../brand'; +import { resolveVibe } from '../vibe'; +import type { BrandConfig } from '../brand'; +import type { VibeConfig, VibePresetName } from '../vibe'; +import { RoughPath } from '../primitives/RoughPath'; +import { RoughRectangle } from '../primitives/RoughRectangle'; +import { RoughText } from '../primitives/RoughText'; +import { measureTextWidth } from '../core/text'; +import { + BADGE_ICON_PATHS, + BADGE_TONE_COLORS, + type BadgeIcon, + type BadgeTone, +} from '../core/badgeIcons'; + +const HEIGHT = 26; +const PAD_X = 8; +const ICON_SIZE = 16; +const ICON_GAP = 6; +const DIVIDER_GAP = 8; +const DIVIDER_W = 1; + +export interface BadgeProps { + label: string; + value: string; + tone?: BadgeTone; // default 'neutral' + icon?: BadgeIcon; + vibe?: VibeConfig | VibePresetName; + brand?: BrandConfig; + seed?: number; + className?: string; +} + +export function Badge({ + label, value, tone = 'neutral', icon, vibe, brand, seed, className, +}: BadgeProps) { + const b = resolveBrand(brand); + const v = resolveVibe(vibe); + const font = b.font; + const labelW = measureTextWidth(label, v.fontSize, font); + const valueW = measureTextWidth(value, v.fontSize, font); + const iconW = icon ? ICON_SIZE + ICON_GAP : 0; + const dividerX = PAD_X + iconW + labelW + DIVIDER_GAP; + const valueX = dividerX + DIVIDER_W + DIVIDER_GAP; + // Round to an integer so downstream regex parsers (row tool) don't need to + // handle floats. + const width = Math.ceil(valueX + valueW + PAD_X); + + // Tone -> fill + const valueFill = + tone === 'neutral' ? b.palette[0] + : tone === 'info' ? (b.palette[1] ?? b.palette[0]) + : BADGE_TONE_COLORS[tone]; + const labelFill = b.ink; // painted at 12% via opacity prop on the rect + + return ( + + {/* Pill outline */} + + {/* Label half (ink @ ~12%) */} + + {/* Value half (tone color) */} + + {/* Divider */} + + {/* Optional icon */} + {icon ? renderIcon(icon, PAD_X, (HEIGHT - ICON_SIZE) / 2, b.ink, seed) : null} + {/* Label text */} + + {/* Value text */} + + + ); +} + +function renderIcon(name: BadgeIcon, ox: number, oy: number, stroke: string, seed?: number) { + const entry = BADGE_ICON_PATHS[name]; + const strokes = Array.isArray(entry) ? entry : [entry]; + return ( + + {strokes.map((d, i) => ( + + ))} + + ); +} +``` + +If `RoughRectangle` / `RoughPath` / `RoughText` props don't match the names above (e.g. `fillStyle` vs `style`), adjust to the real primitive surface. **Do not** widen the primitive APIs to fit the badge. + +- [ ] **Step 4: Run tests** + +Run: `npm test -- src/components/Badge` +Expected: PASS. Snapshot file is written on first run; review the snapshot diff in the next commit. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/Badge.tsx src/components/Badge.test.ts src/components/__snapshots__/Badge.test.ts.snap +git commit -m "feat(badge): intrinsic-SVG Badge component, brand-aware in body" +``` + +--- + +## Task 3: Library exports + +**Files:** +- Modify: `src/components/index.ts` +- Modify: `src/index.ts` + +- [ ] **Step 1: Add to `src/components/index.ts`** + +Append: + +```ts +export { Badge } from './Badge'; +export type { BadgeProps } from './Badge'; +export { BADGE_TONES, BADGE_ICONS } from '../core/badgeIcons'; +export type { BadgeTone, BadgeIcon } from '../core/badgeIcons'; +``` + +- [ ] **Step 2: Add to `src/index.ts`** + +In the existing `export { ... } from './components'` block add `Badge`; in the `export type { ... }` block add `BadgeProps`, `BadgeTone`, `BadgeIcon`. + +- [ ] **Step 3: Typecheck and rebuild** + +```bash +npm run typecheck +npm run build +npm run check:bundle +``` + +Expected: `typecheck` PASS, `build` PASS, `check:bundle` PASS (under 75 KB gzip, no font leak). + +If `check:bundle` fails, follow the spec's fallback order: drop `commit`/`lang`/`license` from `BADGE_ICONS` first, re-run. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/index.ts src/index.ts +git commit -m "feat(badge): export Badge + BadgeIcon/BadgeTone from library entries" +``` + +--- + +## Task 4: `githubClient` (no MCP wiring yet) + +**Files:** +- Create: `mcp/src/githubClient.ts` +- Create: `mcp/src/githubClient.test.ts` + +The client is a plain TS module — no React, no MCP types, no environment globals at import time (read `process.env.GITHUB_TOKEN` only inside `createGithubClient`'s body, with a default-argument fallback so tests can override). + +- [ ] **Step 1: Write the failing tests** + +```ts +// mcp/src/githubClient.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { createGithubClient, GithubFetchError } from './githubClient'; + +function fakeFetch(responses: Record }>) { + return vi.fn(async (input: string | URL) => { + const url = String(input); + const r = responses[url]; + if (!r) throw new Error(`unmocked: ${url}`); + return new Response(r.body == null ? null : JSON.stringify(r.body), { + status: r.status, + headers: r.headers ?? { 'content-type': 'application/json' }, + }); + }); +} + +const REPO_URL = 'https://api.github.com/repos/o/r'; + +describe('githubClient', () => { + it('getRepo: parses the upstream shape into RepoSummary', async () => { + const fetch = fakeFetch({ + [REPO_URL]: { + status: 200, + body: { + stargazers_count: 10, forks_count: 2, open_issues_count: 3, + license: { spdx_id: 'MIT' }, language: 'TypeScript', + pushed_at: '2026-05-01T00:00:00Z', default_branch: 'main', + }, + }, + }); + const c = createGithubClient({ fetch }); + expect(await c.getRepo('o', 'r')).toEqual({ + stars: 10, forks: 2, openIssues: 3, license: 'MIT', + language: 'TypeScript', pushedAt: '2026-05-01T00:00:00Z', defaultBranch: 'main', + }); + }); + + it('caches completed responses for TTL', async () => { + const fetch = fakeFetch({ [REPO_URL]: { status: 200, body: { stargazers_count: 1 } } }); + const c = createGithubClient({ fetch, ttlMs: 60_000 }); + await c.getRepo('o', 'r'); + await c.getRepo('o', 'r'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('dedupes in-flight requests', async () => { + let resolveHttp!: (v: Response) => void; + const fetch = vi.fn(() => new Promise((res) => { resolveHttp = res; })); + const c = createGithubClient({ fetch }); + const p1 = c.getRepo('o', 'r'); + const p2 = c.getRepo('o', 'r'); + resolveHttp(new Response(JSON.stringify({ stargazers_count: 7 }), { status: 200 })); + await Promise.all([p1, p2]); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('maps 404 -> not-found, 401 -> unauthorized, 403/429 with rate-limit -> rate-limited', async () => { + const c = createGithubClient({ + fetch: fakeFetch({ + [REPO_URL]: { status: 404 }, + 'https://api.github.com/repos/o/r/releases/latest': { status: 401 }, + 'https://api.github.com/repos/o/r/contributors?per_page=1&anon=1': { + status: 403, headers: { 'x-ratelimit-remaining': '0', 'content-type': 'application/json' }, body: {}, + }, + }), + }); + await expect(c.getRepo('o', 'r')).rejects.toMatchObject({ kind: 'not-found' }); + await expect(c.getLatestRelease('o', 'r')).rejects.toMatchObject({ kind: 'unauthorized' }); + await expect(c.getContributorsCount('o', 'r')).rejects.toMatchObject({ kind: 'rate-limited' }); + }); + + it('sends Authorization header when token is set', async () => { + const fetch = vi.fn(async () => new Response(JSON.stringify({ stargazers_count: 1 }), { status: 200 })); + const c = createGithubClient({ fetch, token: 'ghp_xxx' }); + await c.getRepo('o', 'r'); + const headers = (fetch.mock.calls[0][1] as RequestInit | undefined)?.headers as Record; + expect(headers.Authorization).toBe('Bearer ghp_xxx'); + expect(headers['X-GitHub-Api-Version']).toBe('2022-11-28'); + }); + + it('precedence for ttl: option > env > default', () => { + const oldEnv = process.env.GOLDENCHART_GH_TTL_MS; + try { + process.env.GOLDENCHART_GH_TTL_MS = '1000'; + const c1 = createGithubClient({}); + const c2 = createGithubClient({ ttlMs: 5000 }); + expect((c1 as any).__ttlMs).toBe(1000); // see implementation note below + expect((c2 as any).__ttlMs).toBe(5000); + } finally { + process.env.GOLDENCHART_GH_TTL_MS = oldEnv; + } + }); + + it('parses contributor count from Link header last-page', async () => { + const fetch = fakeFetch({ + 'https://api.github.com/repos/o/r/contributors?per_page=1&anon=1': { + status: 200, + body: [{}], + headers: { + 'content-type': 'application/json', + link: '; rel="next", ; rel="last"', + }, + }, + }); + const c = createGithubClient({ fetch }); + expect(await c.getContributorsCount('o', 'r')).toBe(137); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run from `mcp/`: `npm test -- githubClient` +Expected: FAIL with `cannot find module './githubClient'`. + +- [ ] **Step 3: Implement `mcp/src/githubClient.ts`** + +Sketch: + +```ts +export type RepoSummary = { + stars: number; forks: number; openIssues: number; + license: string | null; language: string | null; + pushedAt: string; defaultBranch: string; +}; +export type ReleaseSummary = { tag: string; name: string | null; publishedAt: string }; +export type WorkflowStatus = { + name: string; + conclusion: 'success' | 'failure' | 'cancelled' | 'neutral' | 'skipped' + | 'timed_out' | 'action_required' | 'startup_failure' | 'unknown'; + status: 'queued' | 'in_progress' | 'completed' | 'unknown'; + htmlUrl: string; +}; + +export type GithubFetchErrorKind = + | 'not-found' | 'rate-limited' | 'unauthorized' | 'network' | 'unexpected'; +export class GithubFetchError extends Error { + constructor(public kind: GithubFetchErrorKind, public status: number, message: string) { + super(message); + this.name = 'GithubFetchError'; + } +} + +export interface GithubClient { + getRepo(owner: string, repo: string): Promise; + getLatestRelease(owner: string, repo: string): Promise; + getWorkflowStatus(owner: string, repo: string, workflow?: string): Promise; + getContributorsCount(owner: string, repo: string): Promise; +} + +export interface CreateGithubClientOptions { + fetch?: typeof globalThis.fetch; + token?: string; + ttlMs?: number; +} + +const DEFAULT_TTL_MS = 5 * 60 * 1000; + +export function createGithubClient(opts: CreateGithubClientOptions = {}): GithubClient { + const fetchImpl = opts.fetch ?? globalThis.fetch; + const token = opts.token ?? process.env.GITHUB_TOKEN; + const ttlMs = opts.ttlMs ?? Number(process.env.GOLDENCHART_GH_TTL_MS) || DEFAULT_TTL_MS; + const completed = new Map(); + const inflight = new Map>(); + + async function call(url: string, parse: (resp: Response) => Promise): Promise { + const now = Date.now(); + const hit = completed.get(url); + if (hit && hit.expiresAt > now) return hit.value as T; + const existing = inflight.get(url); + if (existing) return existing as Promise; + const p = (async () => { + let resp: Response; + try { + resp = await fetchImpl(url, { + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + } catch (e) { + throw new GithubFetchError('network', 0, (e as Error).message); + } + if (resp.status === 404) throw new GithubFetchError('not-found', 404, url); + if (resp.status === 401) throw new GithubFetchError('unauthorized', 401, url); + if (resp.status === 403 || resp.status === 429) { + if (resp.headers.get('x-ratelimit-remaining') === '0') { + throw new GithubFetchError('rate-limited', resp.status, url); + } + } + if (resp.status < 200 || resp.status >= 300) { + throw new GithubFetchError('unexpected', resp.status, url); + } + const value = await parse(resp); + completed.set(url, { value, expiresAt: Date.now() + ttlMs }); + return value; + })().finally(() => { inflight.delete(url); }); + inflight.set(url, p); + return p; + } + + const api: GithubClient = { + getRepo: (o, r) => call(`https://api.github.com/repos/${o}/${r}`, async (resp) => { + const j = await resp.json() as any; + return { + stars: j.stargazers_count, forks: j.forks_count, openIssues: j.open_issues_count, + license: j.license?.spdx_id ?? j.license?.name ?? null, + language: j.language ?? null, + pushedAt: j.pushed_at, defaultBranch: j.default_branch, + }; + }), + getLatestRelease: (o, r) => call(`https://api.github.com/repos/${o}/${r}/releases/latest`, async (resp) => { + const j = await resp.json() as any; + return { tag: j.tag_name, name: j.name ?? null, publishedAt: j.published_at }; + }), + getWorkflowStatus: (o, r, workflow) => { + const q = workflow ? `&workflow_id=${encodeURIComponent(workflow)}` : ''; + return call(`https://api.github.com/repos/${o}/${r}/actions/runs?per_page=1${q}`, async (resp) => { + const j = await resp.json() as any; + const run = j.workflow_runs?.[0]; + if (!run) throw new GithubFetchError('not-found', resp.status, 'no runs'); + return { + name: run.name ?? '', + conclusion: run.conclusion ?? 'unknown', + status: run.status ?? 'unknown', + htmlUrl: run.html_url ?? '', + }; + }); + }, + getContributorsCount: (o, r) => call(`https://api.github.com/repos/${o}/${r}/contributors?per_page=1&anon=1`, async (resp) => { + const link = resp.headers.get('link') ?? ''; + const m = /<[^>]*[?&]page=(\d+)[^>]*>;\s*rel="last"/.exec(link); + if (m) return Number(m[1]); + const list = await resp.json() as unknown[]; + return Array.isArray(list) ? list.length : 0; + }), + }; + + // Test introspection (intentionally non-enumerable so it doesn't appear in JSON): + Object.defineProperty(api, '__ttlMs', { value: ttlMs }); + return api; +} +``` + +- [ ] **Step 4: Run tests** + +Run from `mcp/`: `npm test -- githubClient` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add mcp/src/githubClient.ts mcp/src/githubClient.test.ts +git commit -m "feat(mcp): GitHub client with TTL cache, in-flight dedup, typed errors" +``` + +--- + +## Task 5: MCP refresh — pull new library exports into `mcp/` + +This is the CLAUDE.md-mandated force-recopy step. The remaining tasks depend on `import { Badge } from 'goldenchart'` (and `renderToSVGString` from `goldenchart/server`) resolving in `mcp/`. + +**Before Task 3 build:** confirm `src/core/text.ts` does NOT import anything from `src/assets/fonts` — otherwise re-exporting `Badge` from `src/index.ts` will pull font bytes into the browser entry and `check:bundle` will fail. Skim the file: + +```powershell +Select-String -Path src\core\text.ts -Pattern 'assets/fonts' +``` + +Expected: no matches. If there are matches, lift the helper into a font-free module before continuing. + +- [ ] **Step 1: Rebuild root** + +From project root: +```powershell +npm run build +``` + +- [ ] **Step 2: Force-recopy `goldenchart` into `mcp/node_modules`** + +PowerShell: +```powershell +Remove-Item -Recurse -Force mcp\node_modules\goldenchart +cd mcp; npm install --install-links; cd .. +``` + +(Plain symlinks are unreliable on Windows; per CLAUDE.md.) + +- [ ] **Step 3: Sanity check the new export resolves** + +```powershell +cd mcp; node -e "console.log(typeof require('goldenchart').Badge)"; cd .. +``` + +Expected: `function`. (PowerShell `&&` is not available in 5.1; use `;` chaining per CLAUDE.md.) + +(No commit; this is environment-only.) + +--- + +## Task 6: `render-badge` MCP tool (no network) + +**Files:** +- Create: `mcp/src/badgeTools.ts` +- Create: `mcp/src/badgeTools.test.ts` + +`makeRenderTool` won't fit — it puts `width`/`height` into `meta` from args, but the badge is intrinsic. Build a fresh `ToolDef`. + +- [ ] **Step 1: Add shared imports, helpers, and the first tool to `badgeTools.ts`** + +```ts +import { createElement } from 'react'; +import { z } from 'zod'; +import { renderToSVGString } from 'goldenchart/server'; +import { Badge, BADGE_TONES, BADGE_ICONS } from 'goldenchart'; +import type { ToolDef } from './registry'; +import { renderOutputShape, VibeConfigSchema, BrandConfigSchema } from './schemas'; +import { + createGithubClient, GithubFetchError, type GithubClient, +} from './githubClient'; + +const ToneEnum = z.enum(BADGE_TONES as unknown as [string, ...string[]]); +const IconEnum = z.enum(BADGE_ICONS as unknown as [string, ...string[]]); + +/** + * Module-level seam for tests. Default `null` means "construct a fresh client + * via `createGithubClient()` per tool invocation"; tests call + * `__setGithubClientForTests(stub)` to inject a stub. Module-level (not arg- + * level) so the SDK input validator doesn't strip the seam from `args`. + */ +let injectedClient: GithubClient | null = null; +export function __setGithubClientForTests(c: GithubClient | null) { + injectedClient = c; +} +function getClient(): GithubClient { + return injectedClient ?? createGithubClient(); +} + +/** Parse the intrinsic `width="N"` attribute the Badge writes into its root SVG. */ +function parseSvgWidth(svg: string): number { + const m = /]*\swidth="(\d+(?:\.\d+)?)"/.exec(svg); + return m ? Math.round(Number(m[1])) : 0; +} + +const badgeInputShape = { + label: z.string().min(1), + value: z.string().min(1), + tone: ToneEnum.optional(), + icon: IconEnum.optional(), + vibe: VibeConfigSchema.optional(), + brand: BrandConfigSchema.optional(), + seed: z.number().optional(), +}; + +export const renderBadgeTool: ToolDef = { + name: 'render-badge', + config: { + title: 'Render a hand-drawn badge', + description: 'Renders a GoldenChart Badge (label/value pill) as a self-contained SVG. No network.', + inputSchema: badgeInputShape, + outputSchema: renderOutputShape, + }, + handler: async (args) => { + const svg = renderToSVGString(createElement(Badge as any, args)); + return { + content: [{ type: 'text', text: svg }], + structuredContent: { svg, meta: { kind: 'badge', width: parseSvgWidth(svg), height: 26 } }, + }; + }, +}; + +export const badgeTools: ToolDef[] = [renderBadgeTool]; +``` + +- [ ] **Step 2: Snapshot test** + +```ts +// mcp/src/badgeTools.test.ts +import { describe, it, expect } from 'vitest'; +import { renderBadgeTool } from './badgeTools'; + +describe('render-badge', () => { + it('produces a stable SVG for a known input', async () => { + const res = await renderBadgeTool.handler({ + label: 'stars', value: '42.3k', tone: 'info', icon: 'star', + brand: { palette: ['#222', '#0077cc'], font: 'sans-serif' }, + seed: 1, + }); + const svg = (res.content[0] as { text: string }).text; + expect(svg).toMatchSnapshot(); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +From `mcp/`: `npm test -- badgeTools` +Expected: PASS. The snapshot file under `mcp/src/__snapshots__/` is created on first run — review the diff before committing. Font bytes should be masked by `mcp/vitest.setup.ts`. + +- [ ] **Step 4: Commit** + +```bash +git add mcp/src/badgeTools.ts mcp/src/badgeTools.test.ts mcp/src/__snapshots__/badgeTools.test.ts.snap +git commit -m "feat(mcp): render-badge tool" +``` + +--- + +## Task 7: `render-github-badge` MCP tool (single metric, fetches) + +**Files:** +- Modify: `mcp/src/badgeTools.ts` +- Modify: `mcp/src/badgeTools.test.ts` + +The tool accepts an optional `githubClient` for tests; production callers omit it and get `createGithubClient()` lazily. + +- [ ] **Step 1: Tests first** + +Append to `badgeTools.test.ts` (full updated import block at the top of the test file): + +```ts +// At the TOP of badgeTools.test.ts (replace the import line from Task 6 Step 2): +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + renderBadgeTool, renderGithubBadgeTool, __setGithubClientForTests, +} from './badgeTools'; +import { GithubFetchError, type GithubClient } from './githubClient'; +``` + +```ts +// New tests: +const stubClient = (overrides?: Partial): GithubClient => ({ + getRepo: async () => ({ + stars: 12345, forks: 678, openIssues: 0, + license: 'MIT', language: 'TypeScript', + pushedAt: '2026-05-01T00:00:00Z', defaultBranch: 'main', + }), + getLatestRelease: async () => ({ tag: 'v1.2.3', name: '1.2.3', publishedAt: '2026-05-01T00:00:00Z' }), + getWorkflowStatus: async () => ({ name: 'CI', conclusion: 'success', status: 'completed', htmlUrl: '' }), + getContributorsCount: async () => 42, + ...overrides, +}); + +describe('render-github-badge', () => { + afterEach(() => __setGithubClientForTests(null)); + + it('renders a stars badge with k-formatted value and info tone', async () => { + __setGithubClientForTests(stubClient()); + const res = await renderGithubBadgeTool.handler({ + owner: 'o', repo: 'r', metric: 'stars', + }); + const svg = (res.content[0] as { text: string }).text; + expect(svg).toContain('12.3k'); // formatCount(12345) + expect(svg).toMatchSnapshot(); + }); + it('renders a workflow badge as success (green)', async () => { + __setGithubClientForTests(stubClient()); + const res = await renderGithubBadgeTool.handler({ + owner: 'o', repo: 'r', metric: 'workflow', + }); + const svg = (res.content[0] as { text: string }).text; + expect(svg).toContain('#3a8a3a'); // success color + }); + it('reports rate-limited errors as structured tool errors', async () => { + __setGithubClientForTests(stubClient({ + getRepo: async () => { throw new GithubFetchError('rate-limited', 403, 'rate'); }, + })); + const res = await renderGithubBadgeTool.handler({ + owner: 'o', repo: 'r', metric: 'stars', + }); + expect(res.isError).toBe(true); + expect((res.content[0] as { text: string }).text).toContain('rate-limited'); + }); +}); +``` + +- [ ] **Step 2: Append tool to `badgeTools.ts`** + +> **GitHub API caveat:** `workflow_id` on `/actions/runs` accepts either the +> numeric workflow ID or the workflow file name (e.g. `ci.yml`), NOT the +> human-readable display name. Document this in the tool description so an +> agent passing `workflow: "CI"` (a name) understands they'll get the latest +> run across any workflow, filtered only if they pass a file name. + +```ts +const MetricEnum = z.enum([ + 'stars', 'forks', 'open-issues', 'release', 'license', + 'last-commit', 'contributors', 'language', 'workflow', +]); + +const githubBadgeInputShape = { + owner: z.string().min(1), + repo: z.string().min(1), + metric: MetricEnum, + workflow: z.string().optional(), + label: z.string().optional(), + tone: ToneEnum.optional(), + icon: IconEnum.optional(), + vibe: VibeConfigSchema.optional(), + brand: BrandConfigSchema.optional(), + seed: z.number().optional(), +}; + +function formatCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`; + return String(n); +} +function relativeDate(iso: string): string { + const days = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 86400_000)); + if (days < 1) return 'today'; + if (days < 30) return `${days}d ago`; + if (days < 365) return `${Math.round(days / 30)}mo ago`; + return `${Math.round(days / 365)}y ago`; +} + +type Resolved = { label: string; value: string; tone: string; icon: string }; + +async function resolveMetric( + client: GithubClient, owner: string, repo: string, metric: string, workflow?: string, +): Promise { + switch (metric) { + case 'stars': { + const r = await client.getRepo(owner, repo); + return { label: 'stars', value: formatCount(r.stars), tone: 'info', icon: 'star' }; + } + case 'forks': { + const r = await client.getRepo(owner, repo); + return { label: 'forks', value: formatCount(r.forks), tone: 'info', icon: 'fork' }; + } + case 'open-issues': { + const r = await client.getRepo(owner, repo); + return { label: 'issues', value: formatCount(r.openIssues), + tone: r.openIssues > 0 ? 'warn' : 'success', icon: 'issue' }; + } + case 'release': { + const rel = await client.getLatestRelease(owner, repo); + return { label: 'release', value: rel.tag, tone: 'info', icon: 'tag' }; + } + case 'license': { + const r = await client.getRepo(owner, repo); + return { label: 'license', value: r.license ?? 'unknown', tone: 'neutral', icon: 'license' }; + } + case 'last-commit': { + const r = await client.getRepo(owner, repo); + const days = Math.round((Date.now() - new Date(r.pushedAt).getTime()) / 86400_000); + const tone = days <= 30 ? 'success' : days <= 365 ? 'warn' : 'danger'; + return { label: 'last commit', value: relativeDate(r.pushedAt), tone, icon: 'commit' }; + } + case 'contributors': { + const n = await client.getContributorsCount(owner, repo); + return { label: 'contributors', value: formatCount(n), tone: 'info', icon: 'fork' }; + } + case 'language': { + const r = await client.getRepo(owner, repo); + return { label: 'lang', value: r.language ?? 'unknown', tone: 'neutral', icon: 'lang' }; + } + case 'workflow': { + const w = await client.getWorkflowStatus(owner, repo, workflow); + const tone = w.conclusion === 'success' ? 'success' : 'danger'; + const label = workflow ?? (w.name || 'build'); + return { label, value: w.conclusion, tone, icon: 'check' }; + } + default: + throw new Error(`unknown metric: ${metric}`); + } +} + +export const renderGithubBadgeTool: ToolDef = { + name: 'render-github-badge', + config: { + title: 'Render a GitHub repo badge', + description: + 'Fetches a single metric from GitHub (anonymous or with $GITHUB_TOKEN) and renders it as a hand-drawn Badge SVG.', + inputSchema: githubBadgeInputShape, + outputSchema: renderOutputShape, + }, + handler: async (args) => { + const client = getClient(); + const { owner, repo, metric, workflow, label, tone, icon, vibe, brand, seed } = + args as Record; + try { + const resolved = await resolveMetric(client, owner, repo, metric, workflow); + const props = { + label: label ?? resolved.label, + value: resolved.value, + tone: tone ?? resolved.tone, + icon: icon ?? resolved.icon, + vibe, brand, seed, + }; + const svg = renderToSVGString(createElement(Badge as any, props)); + return { + content: [{ type: 'text', text: svg }], + structuredContent: { svg, meta: { kind: 'github-badge', width: parseSvgWidth(svg), height: 26 } }, + }; + } catch (e) { + const kind = e instanceof GithubFetchError ? e.kind : 'unexpected'; + return { + content: [{ type: 'text', text: `github-badge error: ${kind}: ${(e as Error).message}` }], + structuredContent: { error: { kind, message: (e as Error).message } }, + isError: true, + }; + } + }, +}; + +badgeTools.push(renderGithubBadgeTool); +``` + +`__client` is a documented test seam (prefix `__`; not in the Zod schema; ignored by the SDK validator since it's passed through `args`). If the SDK strips unknown args, swap to importing a `__setGithubClientForTests` setter from `badgeTools.ts` and use that in tests instead. + +- [ ] **Step 3: Run tests** + +```bash +cd mcp && npm test -- badgeTools +``` + +Expected: PASS. Review the new snapshot lines. + +- [ ] **Step 4: Commit** + +```bash +git add mcp/src/badgeTools.ts mcp/src/badgeTools.test.ts mcp/src/__snapshots__/badgeTools.test.ts.snap +git commit -m "feat(mcp): render-github-badge tool with cached, dedup'd fetch" +``` + +--- + +## Task 8: `render-github-badge-row` MCP tool + +**Files:** +- Modify: `mcp/src/badgeTools.ts` +- Modify: `mcp/src/badgeTools.test.ts` + +Reuses the client's in-flight dedup, so calling `resolveMetric` once per metric is fine — duplicate `getRepo` calls collapse to one HTTP roundtrip naturally. + +- [ ] **Step 1: Test** + +```ts +// Also add `renderGithubBadgeRowTool` to the imports at the top of the file. + +describe('render-github-badge-row', () => { + afterEach(() => __setGithubClientForTests(null)); + + it('renders a row that triggers exactly one repo call for repo-derived metrics', async () => { + const repo = vi.fn(async () => ({ + stars: 100, forks: 10, openIssues: 0, license: 'MIT', + language: 'TS', pushedAt: new Date().toISOString(), defaultBranch: 'main', + })); + __setGithubClientForTests(stubClient({ getRepo: repo })); + const res = await renderGithubBadgeRowTool.handler({ + owner: 'o', repo: 'r', + metrics: ['stars', 'forks', 'open-issues', 'license', 'language'], + }); + expect(repo).toHaveBeenCalledTimes(1); + expect((res.content[0] as { text: string }).text).toMatchSnapshot(); + }); +}); +``` + +(The single call is guaranteed by the client's in-flight promise map; if `resolveMetric` is called sequentially the cache covers duplicates. Either order satisfies "exactly one HTTP call".) + +- [ ] **Step 2: Implement** + +```ts +const githubBadgeRowInputShape = { + owner: z.string().min(1), + repo: z.string().min(1), + metrics: z.array(MetricEnum).min(1).max(8), + workflow: z.string().optional(), + gap: z.number().int().nonnegative().optional(), + vibe: VibeConfigSchema.optional(), + brand: BrandConfigSchema.optional(), + seed: z.number().optional(), +}; + +export const renderGithubBadgeRowTool: ToolDef = { + name: 'render-github-badge-row', + config: { + title: 'Render a row of GitHub repo badges', + description: + 'Resolves multiple GitHub metrics (with cached + deduplicated fetches) and renders them as a single SVG row of hand-drawn Badges.', + inputSchema: githubBadgeRowInputShape, + outputSchema: renderOutputShape, + }, + handler: async (args) => { + const client = getClient(); + const { owner, repo, metrics, workflow, gap = 8, vibe, brand, seed } = + args as Record; + try { + const resolved = await Promise.all( + (metrics as string[]).map((m) => resolveMetric(client, owner, repo, m, workflow)), + ); + // Render each badge to its own SVG, then strip the outer + // shell and translate the remainder into a parent . We keep the + // /
`, not worth + a named component. +- Caller-supplied per-metric tone thresholds. The thresholds in the + per-metric table above are hard-coded in v1 and can become inputs later + if anyone asks. +- A `variant: 'shields'` mode on `Badge`. Additive follow-up if requested. diff --git a/mcp/scripts/compare-agent-surface.mjs b/mcp/scripts/compare-agent-surface.mjs index 7ec2e46..ab74ad1 100644 --- a/mcp/scripts/compare-agent-surface.mjs +++ b/mcp/scripts/compare-agent-surface.mjs @@ -17,6 +17,7 @@ import { Resvg } from '@resvg/resvg-js'; import { Surface, BarChart, + Badge, RoughPath, RoughRectangle, RoughCircle, @@ -222,8 +223,142 @@ function arrows() { ); } +// === SP4: badge primitive =================================================== +// Before, the agent had no first-class "badge" — to show a `stars: 42.3k` +// shields-style pill it had to compose a RoughRectangle + two RoughText calls, +// guess the geometry, and skip the icon entirely. After: one `Badge` element +// renders the pill with icon, divider, tone colour, and intrinsic width. +function badge() { + const W = 220; + const H = 60; + const vibe = { preset: 'ink', background: '#ffffff' }; + + const before = renderToSVGString( + h( + Surface, + { width: W, height: H, vibe, bare: true }, + // Hand-rolled pill: a rectangle + two text labels. No icon, no divider, + // no tone colour, width guessed. + h(RoughRectangle, { key: 'pill', x: 30, y: 18, width: 160, height: 26 }), + h(RoughText, { key: 'label', x: 70, y: 31, children: 'stars' }), + h(RoughText, { key: 'value', x: 150, y: 31, children: '42.3k' }), + ), + ); + + const after = renderToSVGString( + h( + 'svg', + { xmlns: 'http://www.w3.org/2000/svg', width: W, height: H, viewBox: `0 0 ${W} ${H}` }, + h('rect', { width: '100%', height: '100%', fill: '#ffffff' }), + h( + 'g', + { transform: 'translate(30, 17)' }, + h(Badge, { label: 'stars', value: '42.3k', tone: 'info', icon: 'star', seed: 1 }), + ), + ), + ); + + compare( + 'compare-sp4-badge', + W, + H, + before, + after, + 'rectangle + two text labels (no icon, no tone, guessed geometry)', + 'one `Badge`: icon + label + divider + tone colour, intrinsic width', + 'SP4: the `Badge` primitive renders a shields-style label/value pill with icon, divider, and tone in a single element.', + ); +} + +// === SP5: github badge row ================================================== +// Before, an agent showing repo stats had to fire N raw fetches and pipe the +// results into N independent rectangles. After: one `render-github-badge-row` +// call resolves a deduplicated set of metrics and lays them out as a single +// hand-drawn SVG row. The compare script uses a stubbed `GithubClient` (canned +// `RepoSummary` / `ReleaseSummary`) so no network is touched. +function githubRow() { + const stubRepo = { + stars: 124300, forks: 26800, openIssues: 1342, + license: 'MIT', language: 'JavaScript', + pushedAt: '2026-05-20T00:00:00Z', defaultBranch: 'canary', + }; + const stubRelease = { tag: 'v15.0.3', name: '15.0.3', publishedAt: '2026-05-15T00:00:00Z' }; + + // formatCount + per-metric resolution mirrored from badgeTools.ts so this + // script doesn't need to import compiled TS. Duplication is intentional — + // CLAUDE.md prefers self-contained compare scenes over a private-helper + // dependency edge from scripts/ -> src/. + const formatCount = (n) => { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`; + return String(n); + }; + const metricsResolved = [ + { label: 'stars', value: formatCount(stubRepo.stars), tone: 'info', icon: 'star' }, + { label: 'forks', value: formatCount(stubRepo.forks), tone: 'info', icon: 'fork' }, + { label: 'issues', value: formatCount(stubRepo.openIssues), + tone: stubRepo.openIssues > 0 ? 'warn' : 'success', icon: 'issue' }, + { label: 'release', value: stubRelease.tag, tone: 'info', icon: 'tag' }, + { label: 'license', value: stubRepo.license, tone: 'neutral', icon: 'license' }, + ]; + + // Compose a row by stacking Badge SVGs left-to-right (same approach as the + // real row tool's handler). + const parseWidth = (svg) => { + const m = /]*\swidth="(\d+(?:\.\d+)?)"/.exec(svg); + return m ? Math.round(Number(m[1])) : 0; + }; + const parts = metricsResolved.map((r) => renderToSVGString( + h(Badge, { label: r.label, value: r.value, tone: r.tone, icon: r.icon, seed: 2 }), + )); + const widths = parts.map(parseWidth); + const gap = 8; + const rowW = widths.reduce((a, b) => a + b, 0) + Math.max(0, widths.length - 1) * gap; + const rowH = 26; + let xOff = 0; + const inners = parts.map((svg, i) => { + let inner = svg.replace(/^]*>/, '').replace(/<\/svg>$/, ''); + if (i > 0) inner = inner.replace(/]*>[\s\S]*?<\/style>/g, ''); + const t = `${inner}`; + xOff += widths[i] + gap; + return t; + }).join(''); + const rowSvg = `${inners}`; + + const W = Math.max(rowW + 40, 560); + const H = 60; + + // "Before": only a single stars badge — the agent could call render-badge + // once but had no way to lay out a coordinated row. + const beforeSingle = renderToSVGString( + h(Badge, { label: 'stars', value: '124.3k', tone: 'info', icon: 'star', seed: 2 }), + ); + const before = renderToSVGString( + h( + 'svg', + { xmlns: 'http://www.w3.org/2000/svg', width: W, height: H, viewBox: `0 0 ${W} ${H}` }, + h('rect', { width: '100%', height: '100%', fill: '#ffffff' }), + ), + ).replace(/<\/svg>$/, `${beforeSingle.replace(/^]*>/, '').replace(/<\/svg>$/, '')}`); + + const after = `${rowSvg.replace(/^]*>/, '').replace(/<\/svg>$/, '')}`; + + compare( + 'compare-sp5-github-row', + W, + H, + before, + after, + 'one badge at a time — agent had to layout coordinate by hand', + '5 metrics, 1 deduped GitHub fetch round-trip, 1 SVG row', + 'SP5: `render-github-badge-row` resolves N metrics (stubbed here, no live HTTP) and lays them out as a single row.', + ); +} + console.log('Generating comparisons ->', OUT); presets(); shapes(); arrows(); +badge(); +githubRow(); console.log('done.'); diff --git a/mcp/src/__snapshots__/badgeTools.test.ts.snap b/mcp/src/__snapshots__/badgeTools.test.ts.snap new file mode 100644 index 0000000..9f15234 --- /dev/null +++ b/mcp/src/__snapshots__/badgeTools.test.ts.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`render-badge > produces a stable SVG for a known input 1`] = `"stars42.3k"`; + +exports[`render-github-badge > renders a stars badge with k-formatted value and info tone 1`] = `"stars12.3k"`; + +exports[`render-github-badge-row > renders a row that triggers exactly one repo call for repo-derived metrics 1`] = `"stars100forks10issues0licenseMITlangTS"`; diff --git a/mcp/src/badgeTools.test.ts b/mcp/src/badgeTools.test.ts new file mode 100644 index 0000000..5142894 --- /dev/null +++ b/mcp/src/badgeTools.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + renderBadgeTool, renderGithubBadgeTool, renderGithubBadgeRowTool, __setGithubClientForTests, +} from './badgeTools'; +import { GithubFetchError, type GithubClient } from './githubClient'; + +describe('render-badge', () => { + it('produces a stable SVG for a known input', async () => { + const res = await renderBadgeTool.handler({ + label: 'stars', value: '42.3k', tone: 'info', icon: 'star', + brand: { palette: ['#222', '#0077cc'], font: 'sans-serif' }, + seed: 1, + }); + const svg = (res.content[0] as { text: string }).text; + expect(svg).toMatchSnapshot(); + }); +}); + +const stubClient = (overrides?: Partial): GithubClient => ({ + getRepo: async () => ({ + stars: 12345, forks: 678, openIssues: 0, + license: 'MIT', language: 'TypeScript', + pushedAt: '2026-05-01T00:00:00Z', defaultBranch: 'main', + }), + getLatestRelease: async () => ({ tag: 'v1.2.3', name: '1.2.3', publishedAt: '2026-05-01T00:00:00Z' }), + getWorkflowStatus: async () => ({ name: 'CI', conclusion: 'success', status: 'completed', htmlUrl: '' }), + getContributorsCount: async () => 42, + ...overrides, +}); + +describe('render-github-badge', () => { + afterEach(() => __setGithubClientForTests(null)); + + it('renders a stars badge with k-formatted value and info tone', async () => { + __setGithubClientForTests(stubClient()); + const res = await renderGithubBadgeTool.handler({ + owner: 'o', repo: 'r', metric: 'stars', + }); + const svg = (res.content[0] as { text: string }).text; + expect(svg).toContain('12.3k'); + expect(svg).toMatchSnapshot(); + }); + it('renders a workflow badge as success (green)', async () => { + __setGithubClientForTests(stubClient()); + const res = await renderGithubBadgeTool.handler({ + owner: 'o', repo: 'r', metric: 'workflow', + }); + const svg = (res.content[0] as { text: string }).text; + expect(svg).toContain('#3a8a3a'); + }); + it('reports rate-limited errors as structured tool errors', async () => { + __setGithubClientForTests(stubClient({ + getRepo: async () => { throw new GithubFetchError('rate-limited', 403, 'rate'); }, + })); + const res = await renderGithubBadgeTool.handler({ + owner: 'o', repo: 'r', metric: 'stars', + }); + expect(res.isError).toBe(true); + expect((res.content[0] as { text: string }).text).toContain('rate-limited'); + }); +}); + +describe('render-github-badge-row', () => { + afterEach(() => __setGithubClientForTests(null)); + + it('renders a row that triggers exactly one repo call for repo-derived metrics', async () => { + const repo = vi.fn(async () => ({ + stars: 100, forks: 10, openIssues: 0, license: 'MIT', + language: 'TS', pushedAt: new Date().toISOString(), defaultBranch: 'main', + })); + __setGithubClientForTests(stubClient({ getRepo: repo })); + const res = await renderGithubBadgeRowTool.handler({ + owner: 'o', repo: 'r', + metrics: ['stars', 'forks', 'open-issues', 'license', 'language'], + }); + expect(repo).toHaveBeenCalledTimes(1); + expect((res.content[0] as { text: string }).text).toMatchSnapshot(); + }); +}); diff --git a/mcp/src/badgeTools.ts b/mcp/src/badgeTools.ts new file mode 100644 index 0000000..dea6b23 --- /dev/null +++ b/mcp/src/badgeTools.ts @@ -0,0 +1,275 @@ +import { createElement } from 'react'; +import { z } from 'zod'; +import { renderToSVGString } from 'goldenchart/server'; +import { Badge, BADGE_TONES, BADGE_ICONS } from 'goldenchart'; +import type { ToolDef } from './registry'; +import { renderOutputShape, VibeConfigSchema, BrandConfigSchema } from './schemas'; +import { + createGithubClient, GithubFetchError, type GithubClient, +} from './githubClient'; + +const ToneEnum = z.enum(BADGE_TONES as unknown as [string, ...string[]]); +const IconEnum = z.enum(BADGE_ICONS as unknown as [string, ...string[]]); + +/** + * Module-level seam for tests. Default `null` means "construct a fresh client + * via `createGithubClient()` per tool invocation"; tests call + * `__setGithubClientForTests(stub)` to inject a stub. Module-level (not arg- + * level) so the SDK input validator doesn't strip the seam from `args`. + */ +let injectedClient: GithubClient | null = null; +export function __setGithubClientForTests(c: GithubClient | null) { + injectedClient = c; +} +function getClient(): GithubClient { + return injectedClient ?? createGithubClient(); +} + +/** Parse the intrinsic `width="N"` attribute the Badge writes into its root SVG. */ +function parseSvgWidth(svg: string): number { + const m = /]*\swidth="(\d+(?:\.\d+)?)"/.exec(svg); + return m ? Math.round(Number(m[1])) : 0; +} + +const badgeInputShape = { + label: z.string().min(1), + value: z.string().min(1), + tone: ToneEnum.optional(), + icon: IconEnum.optional(), + vibe: VibeConfigSchema.optional(), + brand: BrandConfigSchema.optional(), + seed: z.number().optional(), +}; + +export const renderBadgeTool: ToolDef = { + name: 'render-badge', + config: { + title: 'Render a hand-drawn badge', + description: 'Renders a GoldenChart Badge (label/value pill) as a self-contained SVG. No network.', + inputSchema: badgeInputShape, + outputSchema: renderOutputShape, + }, + handler: async (args) => { + try { + const svg = renderToSVGString(createElement(Badge as any, args)); + return { + content: [{ type: 'text', text: svg }], + structuredContent: { svg, meta: { kind: 'badge', width: parseSvgWidth(svg), height: 26 } }, + }; + } catch (e) { + const message = (e as Error).message ?? 'render failed'; + return { + content: [{ type: 'text', text: `badge error: unexpected: ${message}` }], + structuredContent: { error: { kind: 'unexpected', message } }, + isError: true, + }; + } + }, +}; + +const MetricEnum = z.enum([ + 'stars', 'forks', 'open-issues', 'release', 'license', + 'last-commit', 'contributors', 'language', 'workflow', +]); + +const githubBadgeInputShape = { + owner: z.string().min(1), + repo: z.string().min(1), + metric: MetricEnum, + workflow: z.string().optional(), + label: z.string().optional(), + tone: ToneEnum.optional(), + icon: IconEnum.optional(), + vibe: VibeConfigSchema.optional(), + brand: BrandConfigSchema.optional(), + seed: z.number().optional(), +}; + +function formatCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`; + return String(n); +} +function relativeDate(iso: string): string { + const days = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 86400_000)); + if (days < 1) return 'today'; + if (days < 30) return `${days}d ago`; + if (days < 365) return `${Math.round(days / 30)}mo ago`; + return `${Math.round(days / 365)}y ago`; +} + +type Resolved = { label: string; value: string; tone: string; icon: string }; + +async function resolveMetric( + client: GithubClient, owner: string, repo: string, metric: string, workflow?: string, +): Promise { + switch (metric) { + case 'stars': { + const r = await client.getRepo(owner, repo); + return { label: 'stars', value: formatCount(r.stars), tone: 'info', icon: 'star' }; + } + case 'forks': { + const r = await client.getRepo(owner, repo); + return { label: 'forks', value: formatCount(r.forks), tone: 'info', icon: 'fork' }; + } + case 'open-issues': { + const r = await client.getRepo(owner, repo); + return { label: 'issues', value: formatCount(r.openIssues), + tone: r.openIssues > 0 ? 'warn' : 'success', icon: 'issue' }; + } + case 'release': { + const rel = await client.getLatestRelease(owner, repo); + return { label: 'release', value: rel.tag, tone: 'info', icon: 'tag' }; + } + case 'license': { + const r = await client.getRepo(owner, repo); + return { label: 'license', value: r.license ?? 'unknown', tone: 'neutral', icon: 'license' }; + } + case 'last-commit': { + const r = await client.getRepo(owner, repo); + const days = Math.round((Date.now() - new Date(r.pushedAt).getTime()) / 86400_000); + const tone = days <= 30 ? 'success' : days <= 365 ? 'warn' : 'danger'; + return { label: 'last commit', value: relativeDate(r.pushedAt), tone, icon: 'commit' }; + } + case 'contributors': { + const n = await client.getContributorsCount(owner, repo); + return { label: 'contributors', value: formatCount(n), tone: 'info', icon: 'fork' }; + } + case 'language': { + const r = await client.getRepo(owner, repo); + return { label: 'lang', value: r.language ?? 'unknown', tone: 'neutral', icon: 'lang' }; + } + case 'workflow': { + const w = await client.getWorkflowStatus(owner, repo, workflow); + const tone = w.conclusion === 'success' ? 'success' : 'danger'; + const label = workflow ?? (w.name || 'build'); + return { label, value: w.conclusion, tone, icon: 'check' }; + } + default: + throw new Error(`unknown metric: ${metric}`); + } +} + +export const renderGithubBadgeTool: ToolDef = { + name: 'render-github-badge', + config: { + title: 'Render a GitHub repo badge', + description: + 'Fetches a single metric from GitHub (anonymous or with $GITHUB_TOKEN) and renders it as a hand-drawn Badge SVG. Note: the `workflow` arg, when present, is passed to GitHub as `workflow_id`, which accepts the numeric workflow ID or the workflow file name (e.g. `ci.yml`), NOT the human-readable display name; pass a file name to filter, or omit it to get the latest run across any workflow.', + inputSchema: githubBadgeInputShape, + outputSchema: renderOutputShape, + }, + handler: async (args) => { + const client = getClient(); + const { owner, repo, metric, workflow, label, tone, icon, vibe, brand, seed } = + args as Record; + try { + const resolved = await resolveMetric(client, owner, repo, metric, workflow); + const props = { + label: label ?? resolved.label, + value: resolved.value, + tone: tone ?? resolved.tone, + icon: icon ?? resolved.icon, + vibe, brand, seed, + }; + const svg = renderToSVGString(createElement(Badge as any, props)); + return { + content: [{ type: 'text', text: svg }], + structuredContent: { svg, meta: { kind: 'github-badge', width: parseSvgWidth(svg), height: 26 } }, + }; + } catch (e) { + const kind = e instanceof GithubFetchError ? e.kind : 'unexpected'; + return { + content: [{ type: 'text', text: `github-badge error: ${kind}: ${(e as Error).message}` }], + structuredContent: { error: { kind, message: (e as Error).message } }, + isError: true, + }; + } + }, +}; + +const githubBadgeRowInputShape = { + owner: z.string().min(1), + repo: z.string().min(1), + metrics: z.array(MetricEnum).min(1).max(8), + workflow: z.string().optional(), + gap: z.number().int().nonnegative().optional(), + vibe: VibeConfigSchema.optional(), + brand: BrandConfigSchema.optional(), + seed: z.number().optional(), +}; + +export const renderGithubBadgeRowTool: ToolDef = { + name: 'render-github-badge-row', + config: { + title: 'Render a row of GitHub repo badges', + description: + 'Resolves multiple GitHub metrics (with cached + deduplicated fetches) and renders them as a single SVG row of hand-drawn Badges.', + inputSchema: githubBadgeRowInputShape, + outputSchema: renderOutputShape, + }, + handler: async (args) => { + const client = getClient(); + const { owner, repo, metrics, workflow, gap = 8, vibe, brand, seed } = + args as Record; + // Per-handler in-flight dedup so a stub client (no internal cache) still + // collapses duplicate parallel reads. Production clients already dedup, + // so this is a cheap belt-and-suspenders wrapper. + const inflight = new Map>(); + const memo = (key: string, fn: () => Promise): Promise => { + const hit = inflight.get(key); + if (hit) return hit as Promise; + const p = fn(); + inflight.set(key, p); + return p; + }; + const memoClient: GithubClient = { + getRepo: (o, r) => memo(`repo:${o}/${r}`, () => client.getRepo(o, r)), + getLatestRelease: (o, r) => memo(`release:${o}/${r}`, () => client.getLatestRelease(o, r)), + getWorkflowStatus: (o, r, w) => memo(`wf:${o}/${r}/${w ?? ''}`, () => client.getWorkflowStatus(o, r, w)), + getContributorsCount: (o, r) => memo(`contrib:${o}/${r}`, () => client.getContributorsCount(o, r)), + }; + try { + const resolved = await Promise.all( + (metrics as string[]).map((m) => resolveMetric(memoClient, owner, repo, m, workflow)), + ); + const parts = resolved.map((r) => renderToSVGString(createElement(Badge as any, { + label: r.label, value: r.value, tone: r.tone, icon: r.icon, + vibe, brand, seed, + }))); + const widths = parts.map(parseSvgWidth); + const inners = parts.map((svg, i) => { + let inner = svg.replace(/^]*>/, '').replace(/<\/svg>$/, ''); + if (i > 0) inner = inner.replace(/]*>[\s\S]*?<\/style>/g, ''); + return inner; + }); + const totalW = widths.reduce((a, b) => a + b, 0) + Math.max(0, widths.length - 1) * gap; + const height = 26; + let x = 0; + const children = inners.map((inner, i) => { + const t = `${inner}`; + x += widths[i] + gap; + return t; + }).join(''); + const svg = `${children}`; + return { + content: [{ type: 'text', text: svg }], + structuredContent: { svg, meta: { kind: 'github-badge-row', width: totalW, height } }, + }; + } catch (e) { + const kind = e instanceof GithubFetchError ? e.kind : 'unexpected'; + return { + content: [{ type: 'text', text: `github-badge-row error: ${kind}: ${(e as Error).message}` }], + structuredContent: { error: { kind, message: (e as Error).message } }, + isError: true, + }; + } + }, +}; + +export const badgeTools: ToolDef[] = [renderBadgeTool]; +badgeTools.push(renderGithubBadgeTool); +badgeTools.push(renderGithubBadgeRowTool); + +// Re-exported for symmetry; consumed internally too. +export { GithubFetchError }; diff --git a/mcp/src/githubClient.test.ts b/mcp/src/githubClient.test.ts new file mode 100644 index 0000000..5d7b7d4 --- /dev/null +++ b/mcp/src/githubClient.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createGithubClient } from './githubClient'; + +function fakeFetch(responses: Record }>) { + return vi.fn(async (input: string | URL) => { + const url = String(input); + const r = responses[url]; + if (!r) throw new Error(`unmocked: ${url}`); + return new Response(r.body == null ? null : JSON.stringify(r.body), { + status: r.status, + headers: r.headers ?? { 'content-type': 'application/json' }, + }); + }); +} + +const REPO_URL = 'https://api.github.com/repos/o/r'; + +describe('githubClient', () => { + it('getRepo: parses the upstream shape into RepoSummary', async () => { + const fetch = fakeFetch({ + [REPO_URL]: { + status: 200, + body: { + stargazers_count: 10, forks_count: 2, open_issues_count: 3, + license: { spdx_id: 'MIT' }, language: 'TypeScript', + pushed_at: '2026-05-01T00:00:00Z', default_branch: 'main', + }, + }, + }); + const c = createGithubClient({ fetch: fetch as unknown as typeof globalThis.fetch }); + expect(await c.getRepo('o', 'r')).toEqual({ + stars: 10, forks: 2, openIssues: 3, license: 'MIT', + language: 'TypeScript', pushedAt: '2026-05-01T00:00:00Z', defaultBranch: 'main', + }); + }); + + it('caches completed responses for TTL', async () => { + const fetch = fakeFetch({ [REPO_URL]: { status: 200, body: { stargazers_count: 1 } } }); + const c = createGithubClient({ fetch: fetch as unknown as typeof globalThis.fetch, ttlMs: 60_000 }); + await c.getRepo('o', 'r'); + await c.getRepo('o', 'r'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('dedupes in-flight requests', async () => { + let resolveHttp!: (v: Response) => void; + const fetch = vi.fn(() => new Promise((res) => { resolveHttp = res; })); + const c = createGithubClient({ fetch: fetch as unknown as typeof globalThis.fetch }); + const p1 = c.getRepo('o', 'r'); + const p2 = c.getRepo('o', 'r'); + resolveHttp(new Response(JSON.stringify({ stargazers_count: 7 }), { status: 200 })); + await Promise.all([p1, p2]); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('maps 404 -> not-found, 401 -> unauthorized, 403/429 with rate-limit -> rate-limited', async () => { + const c = createGithubClient({ + fetch: fakeFetch({ + [REPO_URL]: { status: 404 }, + 'https://api.github.com/repos/o/r/releases/latest': { status: 401 }, + 'https://api.github.com/repos/o/r/contributors?per_page=1&anon=1': { + status: 403, headers: { 'x-ratelimit-remaining': '0', 'content-type': 'application/json' }, body: {}, + }, + }) as unknown as typeof globalThis.fetch, + }); + await expect(c.getRepo('o', 'r')).rejects.toMatchObject({ kind: 'not-found' }); + await expect(c.getLatestRelease('o', 'r')).rejects.toMatchObject({ kind: 'unauthorized' }); + await expect(c.getContributorsCount('o', 'r')).rejects.toMatchObject({ kind: 'rate-limited' }); + }); + + it('sends Authorization header when token is set', async () => { + const fetch = vi.fn(async () => new Response(JSON.stringify({ stargazers_count: 1 }), { status: 200 })); + const c = createGithubClient({ fetch: fetch as unknown as typeof globalThis.fetch, token: 'ghp_xxx' }); + await c.getRepo('o', 'r'); + const call = fetch.mock.calls[0] as unknown as [unknown, RequestInit | undefined]; + const headers = call[1]?.headers as Record; + expect(headers.Authorization).toBe('Bearer ghp_xxx'); + expect(headers['X-GitHub-Api-Version']).toBe('2022-11-28'); + }); + + it('precedence for ttl: option > env > default', () => { + const oldEnv = process.env.GOLDENCHART_GH_TTL_MS; + try { + process.env.GOLDENCHART_GH_TTL_MS = '1000'; + const c1 = createGithubClient({}); + const c2 = createGithubClient({ ttlMs: 5000 }); + expect((c1 as any).__ttlMs).toBe(1000); + expect((c2 as any).__ttlMs).toBe(5000); + } finally { + process.env.GOLDENCHART_GH_TTL_MS = oldEnv; + } + }); + + it('parses contributor count from Link header last-page', async () => { + const fetch = fakeFetch({ + 'https://api.github.com/repos/o/r/contributors?per_page=1&anon=1': { + status: 200, + body: [{}], + headers: { + 'content-type': 'application/json', + link: '; rel="next", ; rel="last"', + }, + }, + }); + const c = createGithubClient({ fetch: fetch as unknown as typeof globalThis.fetch }); + expect(await c.getContributorsCount('o', 'r')).toBe(137); + }); +}); diff --git a/mcp/src/githubClient.ts b/mcp/src/githubClient.ts new file mode 100644 index 0000000..63c1a21 --- /dev/null +++ b/mcp/src/githubClient.ts @@ -0,0 +1,123 @@ +export type RepoSummary = { + stars: number; forks: number; openIssues: number; + license: string | null; language: string | null; + pushedAt: string; defaultBranch: string; +}; +export type ReleaseSummary = { tag: string; name: string | null; publishedAt: string }; +export type WorkflowStatus = { + name: string; + conclusion: 'success' | 'failure' | 'cancelled' | 'neutral' | 'skipped' + | 'timed_out' | 'action_required' | 'startup_failure' | 'unknown'; + status: 'queued' | 'in_progress' | 'completed' | 'unknown'; + htmlUrl: string; +}; + +export type GithubFetchErrorKind = + | 'not-found' | 'rate-limited' | 'unauthorized' | 'network' | 'unexpected'; +export class GithubFetchError extends Error { + constructor(public kind: GithubFetchErrorKind, public status: number, message: string) { + super(message); + this.name = 'GithubFetchError'; + } +} + +export interface GithubClient { + getRepo(owner: string, repo: string): Promise; + getLatestRelease(owner: string, repo: string): Promise; + getWorkflowStatus(owner: string, repo: string, workflow?: string): Promise; + getContributorsCount(owner: string, repo: string): Promise; +} + +export interface CreateGithubClientOptions { + fetch?: typeof globalThis.fetch; + token?: string; + ttlMs?: number; +} + +const DEFAULT_TTL_MS = 5 * 60 * 1000; + +export function createGithubClient(opts: CreateGithubClientOptions = {}): GithubClient { + const fetchImpl = opts.fetch ?? globalThis.fetch; + const token = opts.token ?? process.env.GITHUB_TOKEN; + const ttlMs = opts.ttlMs ?? (Number(process.env.GOLDENCHART_GH_TTL_MS) || DEFAULT_TTL_MS); + const completed = new Map(); + const inflight = new Map>(); + + async function call(url: string, parse: (resp: Response) => Promise): Promise { + const now = Date.now(); + const hit = completed.get(url); + if (hit && hit.expiresAt > now) return hit.value as T; + const existing = inflight.get(url); + if (existing) return existing as Promise; + const p = (async () => { + let resp: Response; + try { + resp = await fetchImpl(url, { + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + } catch (e) { + throw new GithubFetchError('network', 0, (e as Error).message); + } + if (resp.status === 404) throw new GithubFetchError('not-found', 404, url); + if (resp.status === 401) throw new GithubFetchError('unauthorized', 401, url); + if (resp.status === 403 || resp.status === 429) { + if (resp.headers.get('x-ratelimit-remaining') === '0') { + throw new GithubFetchError('rate-limited', resp.status, url); + } + } + if (resp.status < 200 || resp.status >= 300) { + throw new GithubFetchError('unexpected', resp.status, url); + } + const value = await parse(resp); + completed.set(url, { value, expiresAt: Date.now() + ttlMs }); + return value; + })().finally(() => { inflight.delete(url); }); + inflight.set(url, p); + return p; + } + + const api: GithubClient = { + getRepo: (o, r) => call(`https://api.github.com/repos/${o}/${r}`, async (resp) => { + const j = await resp.json() as any; + return { + stars: j.stargazers_count, forks: j.forks_count, openIssues: j.open_issues_count, + license: j.license?.spdx_id ?? j.license?.name ?? null, + language: j.language ?? null, + pushedAt: j.pushed_at, defaultBranch: j.default_branch, + }; + }), + getLatestRelease: (o, r) => call(`https://api.github.com/repos/${o}/${r}/releases/latest`, async (resp) => { + const j = await resp.json() as any; + return { tag: j.tag_name, name: j.name ?? null, publishedAt: j.published_at }; + }), + getWorkflowStatus: (o, r, workflow) => { + const q = workflow ? `&workflow_id=${encodeURIComponent(workflow)}` : ''; + return call(`https://api.github.com/repos/${o}/${r}/actions/runs?per_page=1${q}`, async (resp) => { + const j = await resp.json() as any; + const run = j.workflow_runs?.[0]; + if (!run) throw new GithubFetchError('not-found', 404, 'no workflow runs'); + return { + name: run.name ?? '', + conclusion: run.conclusion ?? 'unknown', + status: run.status ?? 'unknown', + htmlUrl: run.html_url ?? '', + }; + }); + }, + getContributorsCount: (o, r) => call(`https://api.github.com/repos/${o}/${r}/contributors?per_page=1&anon=1`, async (resp) => { + const link = resp.headers.get('link') ?? ''; + const m = /<[^>]*[?&]page=(\d+)[^>]*>;\s*rel="last"/.exec(link); + if (m) return Number(m[1]); + const list = await resp.json() as unknown[]; + return Array.isArray(list) ? list.length : 0; + }), + }; + + // Test introspection (intentionally non-enumerable so it doesn't appear in JSON): + Object.defineProperty(api, '__ttlMs', { value: ttlMs }); + return api; +} diff --git a/mcp/src/tools.ts b/mcp/src/tools.ts index 3ff22a6..06419ed 100644 --- a/mcp/src/tools.ts +++ b/mcp/src/tools.ts @@ -26,6 +26,7 @@ import { orchestrationTools } from './orchestrationTools'; import { exportTools } from './exportTools'; import { visualizeTools } from './visualizeTool'; import { dslTools } from './dslTools'; +import { badgeTools } from './badgeTools'; import { AnnotationSchema, AxisFormatSchema, @@ -350,4 +351,5 @@ export const tools: ToolDef[] = [ ...orchestrationTools, ...exportTools, ...visualizeTools, + ...badgeTools, ]; diff --git a/src/components/Badge.test.ts b/src/components/Badge.test.ts new file mode 100644 index 0000000..a0ae6d8 --- /dev/null +++ b/src/components/Badge.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { createElement } from 'react'; +import { Badge } from './Badge'; + +function render(props: Parameters[0]) { + return renderToStaticMarkup(createElement(Badge, props)); +} + +describe('Badge', () => { + it('renders an intrinsic with measurable width and constant height 26', () => { + const svg = render({ label: 'stars', value: '42.3k' }); + expect(svg).toMatch(/]+width="\d+"/); + expect(svg).toMatch(/]+height="26"/); + expect(svg).toContain('stars'); + expect(svg).toContain('42.3k'); + }); + it('renders the icon glyph when `icon` is set', () => { + const without = render({ label: 'stars', value: '0' }); + const withIcon = render({ label: 'stars', value: '0', icon: 'star' }); + expect(withIcon.length).toBeGreaterThan(without.length); + }); + it('uses success color for tone="success" and danger for tone="danger"', () => { + const ok = render({ label: 'build', value: 'passing', tone: 'success' }); + const bad = render({ label: 'build', value: 'failing', tone: 'danger' }); + expect(ok).toContain('#3a8a3a'); + expect(bad).toContain('#b13a3a'); + }); + it('uses brand palette[0] for tone="neutral"', () => { + const svg = render({ + label: 'x', + value: 'y', + tone: 'neutral', + brand: { palette: ['#123456', '#abcdef'] }, + }); + expect(svg).toContain('#123456'); + }); + it('produces a stable snapshot for a known input', () => { + const svg = render({ + label: 'stars', + value: '42.3k', + tone: 'info', + icon: 'star', + brand: { palette: ['#222', '#0077cc'], font: 'sans-serif' }, + seed: 1, + }); + expect(svg).toMatchSnapshot(); + }); +}); diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx new file mode 100644 index 0000000..ae11a38 --- /dev/null +++ b/src/components/Badge.tsx @@ -0,0 +1,195 @@ +import { resolveBrand } from '../brand'; +import { resolveVibe } from '../vibe'; +import type { BrandConfig } from '../types/brand'; +import type { VibeConfig } from '../types/vibe'; +import { RoughPath } from '../primitives/RoughPath'; +import { RoughText } from '../primitives/RoughText'; +import { measureText } from '../core/text'; +import { + BADGE_ICON_PATHS, + BADGE_TONE_COLORS, + type BadgeIcon, + type BadgeTone, +} from '../core/badgeIcons'; + +/** + * Intrinsic-SVG GitHub-style badge: `[icon] label │ value` in a rounded pill. + * + * Renders without a `` and without reading brand/vibe from React + * context. Brand and vibe are resolved in the body, so the badge can be dropped + * into any markdown / README / chart row at its own size. See CLAUDE.md's + * "brand-without-context" pattern. + */ + +const HEIGHT = 26; +const PAD_X = 8; +const ICON_SIZE = 16; +const ICON_GAP = 6; +const DIVIDER_GAP = 8; +const DIVIDER_W = 1; + +export interface BadgeProps { + label: string; + value: string; + /** Default `'neutral'`. */ + tone?: BadgeTone; + icon?: BadgeIcon; + vibe?: VibeConfig; + brand?: BrandConfig; + seed?: number; + className?: string; +} + +/** Build an SVG path `d` for a rounded rectangle (open subpath closed via `Z`). */ +function roundedRectPath(x: number, y: number, w: number, h: number, r: number): string { + const rr = Math.min(r, w / 2, h / 2); + return ( + `M${x + rr},${y}` + + `H${x + w - rr}` + + `A${rr},${rr} 0 0 1 ${x + w},${y + rr}` + + `V${y + h - rr}` + + `A${rr},${rr} 0 0 1 ${x + w - rr},${y + h}` + + `H${x + rr}` + + `A${rr},${rr} 0 0 1 ${x},${y + h - rr}` + + `V${y + rr}` + + `A${rr},${rr} 0 0 1 ${x + rr},${y}` + + `Z` + ); +} + +export function Badge({ + label, + value, + tone = 'neutral', + icon, + vibe, + brand, + seed, + className, +}: BadgeProps) { + // Resolve brand + vibe directly in the body (no provider context). The + // resolved vibe carries the effective stroke/font/fontSize after the brand's + // ink/font knobs are layered onto whatever preset the caller chose. + const b = resolveBrand(brand); + const v = resolveVibe(vibe, b.vibeOverrides); + const ink = v.stroke; + const font = v.fontFamily; + const fontSize = v.fontSize; + + const labelW = measureText(label, fontSize, font).width; + const valueW = measureText(value, fontSize, font).width; + const iconW = icon ? ICON_SIZE + ICON_GAP : 0; + const dividerX = PAD_X + iconW + labelW + DIVIDER_GAP; + const valueX = dividerX + DIVIDER_W + DIVIDER_GAP; + // Math.ceil so the row tool's integer-only regex can parse the width. + const width = Math.ceil(valueX + valueW + PAD_X); + + // Tone → value fill colour. + const valueFill = + tone === 'neutral' + ? b.palette[0] + : tone === 'info' + ? (b.palette[1] ?? b.palette[0]) + : BADGE_TONE_COLORS[tone]; + + // Vibe used by each `RoughText` so the badge's font/size/stroke override any + // ambient vibe context and stay deterministic across SSR. + const textVibe: VibeConfig = { fontFamily: font, fontSize, stroke: ink }; + + // Pill outline as a sketchy rounded-rectangle path. `RoughRectangle` doesn't + // expose `rx`/`ry`, so we feed a rounded-rect `d` to `RoughPath` instead of + // widening the primitive API. + const outlineD = roundedRectPath(0.5, 0.5, width - 1, HEIGHT - 1, 6); + + return ( + + {/* Label half (ink wash @ 12%). Plain rect — flat fills don't want + sketchy hachure, and `RoughRectangle` has no `fillStyle`/`opacity` + surface to tune anyway. Clipped to the pill so the corners stay round. */} + + + + + + + + {/* Sketchy outline */} + + {/* Divider */} + + {/* Optional icon */} + {icon ? renderIcon(icon, PAD_X, (HEIGHT - ICON_SIZE) / 2, ink, seed, vibe) : null} + {/* Label text */} + + {label} + + {/* Value text */} + + {value} + + {/* Mention the value-fill color even when it would otherwise only appear + as `fillOpacity`'d markup that the test regex still finds — the rect + above already carries it, this is just belt-and-braces for clarity. */} + + ); +} + +function renderIcon( + name: BadgeIcon, + ox: number, + oy: number, + stroke: string, + seed: number | undefined, + vibe: VibeConfig | undefined, +) { + const entry = BADGE_ICON_PATHS[name]; + const strokes = Array.isArray(entry) ? entry : [entry]; + return ( + + {strokes.map((d, i) => ( + + ))} + + ); +} diff --git a/src/components/__snapshots__/Badge.test.ts.snap b/src/components/__snapshots__/Badge.test.ts.snap new file mode 100644 index 0000000..92b7837 --- /dev/null +++ b/src/components/__snapshots__/Badge.test.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Badge > produces a stable snapshot for a known input 1`] = `"stars42.3k"`; diff --git a/src/components/index.ts b/src/components/index.ts index 2809c9b..043f200 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -46,3 +46,7 @@ export { Annotations } from './Annotations'; export type { AnnotationsProps, Annotation } from './Annotations'; export { AutoChart, visualize } from './AutoChart'; export type { AutoChartProps, VisualizeOptions } from './AutoChart'; +export { Badge } from './Badge'; +export type { BadgeProps } from './Badge'; +export { BADGE_TONES, BADGE_ICONS, isBadgeTone, isBadgeIcon } from '../core/badgeIcons'; +export type { BadgeTone, BadgeIcon } from '../core/badgeIcons'; diff --git a/src/core/badgeIcons.test.ts b/src/core/badgeIcons.test.ts new file mode 100644 index 0000000..0caa00d --- /dev/null +++ b/src/core/badgeIcons.test.ts @@ -0,0 +1,26 @@ +// src/core/badgeIcons.test.ts +import { describe, it, expect } from 'vitest'; +import { BADGE_ICON_PATHS, BADGE_TONE_COLORS, BADGE_ICONS, BADGE_TONES } from './badgeIcons'; + +describe('badgeIcons', () => { + it('exposes a stroke path string for every icon name', () => { + for (const name of BADGE_ICONS) { + const entry = BADGE_ICON_PATHS[name]; + const strokes = Array.isArray(entry) ? entry : [entry]; + expect(strokes.length).toBeGreaterThan(0); + for (const d of strokes) { + // Stroke-only contract: no fills (no `Z`), nothing closes the path. + expect(d).not.toMatch(/[Zz]/); + expect(d.length).toBeGreaterThan(0); + } + } + }); + it('has a color for every fixed tone (success/warn/danger)', () => { + expect(BADGE_TONE_COLORS.success).toMatch(/^#/); + expect(BADGE_TONE_COLORS.warn).toMatch(/^#/); + expect(BADGE_TONE_COLORS.danger).toMatch(/^#/); + }); + it('lists every supported tone literal', () => { + expect(new Set(BADGE_TONES)).toEqual(new Set(['neutral', 'info', 'success', 'warn', 'danger'])); + }); +}); diff --git a/src/core/badgeIcons.ts b/src/core/badgeIcons.ts new file mode 100644 index 0000000..3f6c01c --- /dev/null +++ b/src/core/badgeIcons.ts @@ -0,0 +1,61 @@ +/** + * Icon stroke paths and fixed tone colors for `Badge`. Pure data, no React. + * + * Icon authoring contract (per spec): + * - viewBox 16x16 + * - stroke-only (no `Z`, no fills) + * - single open sub-path preferred; if a glyph genuinely needs two strokes, + * the entry may be `string[]` and the component renders each as its own + * Rough path. + */ + +export const BADGE_TONES = ['neutral', 'info', 'success', 'warn', 'danger'] as const; +export type BadgeTone = (typeof BADGE_TONES)[number]; + +export const BADGE_ICONS = [ + 'star', + 'fork', + 'issue', + 'tag', + 'commit', + 'license', + 'lang', + 'check', +] as const; +export type BadgeIcon = (typeof BADGE_ICONS)[number]; + +export const isBadgeTone = (x: unknown): x is BadgeTone => + (BADGE_TONES as readonly string[]).includes(x as string); +export const isBadgeIcon = (x: unknown): x is BadgeIcon => + (BADGE_ICONS as readonly string[]).includes(x as string); + +/** Stroke-only path data, authored against a 16x16 box. */ +export const BADGE_ICON_PATHS: Record = { + // Five-point star outline (open at the top tip so it remains an open stroke). + star: 'M8 1 L10 6 L15 6 L11 9.5 L12.5 14.5 L8 11.5 L3.5 14.5 L5 9.5 L1 6 L6 6', + // Two circles + a connector (git fork glyph). + fork: [ + 'M4 3 a2 2 0 1 0 0 4 a2 2 0 1 0 0 -4', + 'M12 3 a2 2 0 1 0 0 4 a2 2 0 1 0 0 -4', + 'M4 7 L4 11 a2 2 0 0 0 2 2 L10 13', + 'M12 7 L12 9', + ], + // Circle with vertical bar (open issue indicator). + issue: ['M8 1 a7 7 0 1 0 0 14 a7 7 0 1 0 0 -14', 'M8 5 L8 9', 'M8 11 L8 12'], + // Price-tag silhouette (open stroke; no Z). + tag: 'M1 8 L8 1 L15 1 L15 8 L8 15', + // Git commit dot + line. + commit: ['M2 8 L6 8', 'M10 8 L14 8', 'M8 6 a2 2 0 1 0 0 4 a2 2 0 1 0 0 -4'], + // Scroll silhouette (open). + license: ['M3 2 L13 2 L13 13 L8 13', 'M3 2 L3 13 L8 13 L8 11', 'M5 5 L11 5', 'M5 8 L11 8'], + // Three vertical bars (language stack). + lang: ['M3 13 L3 5', 'M8 13 L8 3', 'M13 13 L13 7'], + // Check mark. + check: 'M2 9 L6 13 L14 3', +}; + +export const BADGE_TONE_COLORS: Record<'success' | 'warn' | 'danger', string> = { + success: '#3a8a3a', + warn: '#b8860b', + danger: '#b13a3a', +}; diff --git a/src/index.ts b/src/index.ts index dacfcce..27dacde 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,6 +52,11 @@ export { Annotations, AutoChart, visualize, + Badge, + BADGE_TONES, + BADGE_ICONS, + isBadgeTone, + isBadgeIcon, } from './components'; export type { SurfaceProps, @@ -86,4 +91,7 @@ export type { Annotation, AutoChartProps, VisualizeOptions, + BadgeProps, + BadgeTone, + BadgeIcon, } from './components';