diff --git a/corpus/frontend/ui-ux/dark-mode-principles.yaml b/corpus/frontend/ui-ux/dark-mode-principles.yaml new file mode 100644 index 0000000..e45e6c1 --- /dev/null +++ b/corpus/frontend/ui-ux/dark-mode-principles.yaml @@ -0,0 +1,19 @@ +name: dark-mode-principles +domain: color +rule: "Dark mode: redesign, don't invert. Warm charcoal, not black." +detail: >- + Background: oklch(0.13 0.008 265) - warm charcoal, not #000. Text: + oklch(0.94 0.008 265) - off-white, not #fff. Higher elevation = lighter bg + (shadows invisible on dark). Reduce saturation slightly (vivid on dark = + neon). Primary accents brighten: brand-600 → brand-400. +cssExample: | + .dark { + --color-bg: oklch(0.13 0.008 265); /* warm charcoal */ + --color-text: oklch(0.94 0.008 265); /* off-white */ + /* surface-1 */ --color-surface: oklch(0.16 0.008 265); + /* surface-2 */ --color-card: oklch(0.19 0.008 265); + } +antiPatterns: + - Pure black background (#000) + - Pure white text (#fff) on dark + - Inverting light mode colors directly diff --git a/corpus/frontend/ui-ux/index.yaml b/corpus/frontend/ui-ux/index.yaml new file mode 100644 index 0000000..e2291f7 --- /dev/null +++ b/corpus/frontend/ui-ux/index.yaml @@ -0,0 +1,10 @@ +namespace: frontend.ui-ux +principles: + type-scale: + file: type-scale.yaml + wcag-contrast: + file: wcag-contrast.yaml + dark-mode-principles: + file: dark-mode-principles.yaml + touch-targets: + file: touch-targets.yaml diff --git a/corpus/frontend/ui-ux/touch-targets.yaml b/corpus/frontend/ui-ux/touch-targets.yaml new file mode 100644 index 0000000..e4937cd --- /dev/null +++ b/corpus/frontend/ui-ux/touch-targets.yaml @@ -0,0 +1,17 @@ +name: touch-targets +domain: accessibility +rule: "Touch targets: min 44×44px (WCAG), recommended 48×48px. Gap ≥ 8px between targets." +detail: >- + Visual size ≠ touch target. A 34px button can have a 44px hit area via + padding or ::after pseudo-element. Gap prevents accidental taps on adjacent + targets. +cssExample: | + /* Hit area expansion */ + .btn-small::after { + content: ''; + position: absolute; + inset: -8px; + } +antiPatterns: + - "< 44px touch targets on mobile" + - Adjacent buttons with no gap diff --git a/corpus/frontend/ui-ux/type-scale.yaml b/corpus/frontend/ui-ux/type-scale.yaml new file mode 100644 index 0000000..77e81ac --- /dev/null +++ b/corpus/frontend/ui-ux/type-scale.yaml @@ -0,0 +1,17 @@ +name: type-scale +domain: typography +rule: Use mathematical ratios for type scale, not arbitrary sizes. +detail: >- + Common ratios: 1.25 (Major Third), 1.333 (Perfect Fourth), 1.414 (Augmented + Fourth). Recommended web scale: Display 48-72px, H1 40-56px, H2 28-40px, + H3 20-24px, Subtitle 16-20px, Body 16px, Body-sm 14px, Caption 13px, + Overline 12px. +cssExample: | + /* fluid headings, fixed body */ + font-size: clamp(2.5rem, 4vw, 3.5rem); /* h1 */ + font-size: clamp(1.75rem, 3vw, 2.5rem); /* h2 */ + font-size: 1rem; /* body - fixed */ +antiPatterns: + - Random px values with no ratio + - Fluid body text (causes reflow) + - More than 2 type families diff --git a/corpus/frontend/ui-ux/wcag-contrast.yaml b/corpus/frontend/ui-ux/wcag-contrast.yaml new file mode 100644 index 0000000..c557c7e --- /dev/null +++ b/corpus/frontend/ui-ux/wcag-contrast.yaml @@ -0,0 +1,12 @@ +name: wcag-contrast +domain: color +rule: "Body text: 4.5:1 (AA). Large text ≥18px bold or ≥24px: 3:1 (AA). UI components: 3:1." +detail: >- + AAA (enhanced): 7:1 for body, 4.5:1 for large. Fix: reduce OKLCH Lightness + (L) of status colors from ~0.63 to ~0.55. Keep C and H unchanged. +examples: + - "Run: npm install wcag-contrast" + - "Online: https://webaim.org/resources/contrastchecker/" +antiPatterns: + - Testing only in light mode + - Assuming brand colors pass without checking diff --git a/corpus/index.yaml b/corpus/index.yaml index 2f1978e..91bffc9 100644 --- a/corpus/index.yaml +++ b/corpus/index.yaml @@ -11,3 +11,5 @@ namespaces: index: frontend/design-tokens/index.yaml frontend.shadcn: index: frontend/shadcn/index.yaml + frontend.ui-ux: + index: frontend/ui-ux/index.yaml diff --git a/src/plugins/ui-ux/tools/get-principle.ts b/src/plugins/ui-ux/tools/get-principle.ts index 45594ef..d1309be 100644 --- a/src/plugins/ui-ux/tools/get-principle.ts +++ b/src/plugins/ui-ux/tools/get-principle.ts @@ -1,6 +1,101 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import YAML from "yaml"; import { z } from "zod"; -import { PRINCIPLES } from "../data.js"; +import { PRINCIPLES, type Principle } from "../data.js"; + +type CorpusIndex = { + namespaces?: { + "frontend.ui-ux"?: { + index?: string; + }; + }; +}; + +type CorpusNamespaceIndex = { + namespace?: string; + principles?: Record; +}; + +type CorpusPrincipleEntry = { + name?: string; + domain?: string; + rule?: string; + detail?: string; + examples?: string[]; + antiPatterns?: string[]; + cssExample?: string; +}; + +type LoadedCorpusPrinciple = { principle: Principle; source: string }; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); +const corpusRoot = join(moduleDir, "../../../../corpus"); +const corpusNamespace = "frontend.ui-ux"; + +const cachedCorpusPrinciples = new Map(); + +function normalize(name: string): string { + return name.toLowerCase().trim(); +} + +function loadCorpusPrinciple(name: string): LoadedCorpusPrinciple | null { + const key = normalize(name); + if (cachedCorpusPrinciples.has(key)) { + return cachedCorpusPrinciples.get(key) ?? null; + } + + try { + const indexRaw = readFileSync(join(corpusRoot, "index.yaml"), "utf8"); + const index = YAML.parse(indexRaw) as CorpusIndex | null; + const namespaceIndexPath = index?.namespaces?.[corpusNamespace]?.index; + if (!namespaceIndexPath) { + cachedCorpusPrinciples.set(key, null); + return null; + } + + const namespaceRaw = readFileSync(join(corpusRoot, namespaceIndexPath), "utf8"); + const namespaceIndex = YAML.parse(namespaceRaw) as CorpusNamespaceIndex | null; + const principlePath = namespaceIndex?.principles?.[key]?.file; + if (!principlePath) { + cachedCorpusPrinciples.set(key, null); + return null; + } + + const raw = readFileSync(join(corpusRoot, "frontend/ui-ux", principlePath), "utf8"); + const entry = YAML.parse(raw) as CorpusPrincipleEntry | null; + if ( + !entry || + normalize(entry.name ?? "") !== key || + !entry.domain || + !entry.rule || + !entry.detail + ) { + cachedCorpusPrinciples.set(key, null); + return null; + } + + const loaded: LoadedCorpusPrinciple = { + principle: { + name: entry.name ?? name, + domain: entry.domain as Principle["domain"], + rule: entry.rule, + detail: entry.detail, + examples: entry.examples, + antiPatterns: entry.antiPatterns, + cssExample: entry.cssExample, + }, + source: corpusNamespace, + }; + cachedCorpusPrinciples.set(key, loaded); + return loaded; + } catch { + cachedCorpusPrinciples.set(key, null); + return null; + } +} export function register(server: McpServer): void { server.tool( @@ -10,9 +105,10 @@ export function register(server: McpServer): void { name: z.string().describe("Principle name (e.g. 'type-scale', 'wcag-contrast', 'dark-mode-principles', 'touch-targets', 'easing-rules')"), }, async ({ name }) => { - const principle = PRINCIPLES.find( - (p) => p.name.toLowerCase() === name.toLowerCase() - ); + const corpusEntry = loadCorpusPrinciple(name); + const principle = + corpusEntry?.principle ?? + PRINCIPLES.find((p) => p.name.toLowerCase() === name.toLowerCase()); if (!principle) { const available = PRINCIPLES.map((p) => p.name).join(", "); @@ -41,6 +137,10 @@ export function register(server: McpServer): void { for (const ap of principle.antiPatterns) text += `- ❌ ${ap}\n`; } + if (corpusEntry) { + text += `\n**Corpus Source:** ${corpusEntry.source}`; + } + return { content: [{ type: "text", text }] }; } ); diff --git a/tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts b/tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts new file mode 100644 index 0000000..2249517 --- /dev/null +++ b/tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test"; +import { captureTool, extractTextContent } from "./helpers"; +import { register as registerUiUxGetPrinciple } from "../src/plugins/ui-ux/tools/get-principle.ts"; + +const uiUxGetPrinciple = captureTool(registerUiUxGetPrinciple); + +test("ui_ux_get_principle prefers corpus metadata for type-scale", async () => { + const result = await uiUxGetPrinciple.invoke({ name: "type-scale" }); + const text = extractTextContent(result); + + expect(text).toContain("# type-scale [typography]"); + expect(text).toContain("**Corpus Source:** frontend.ui-ux"); + expect(text).toContain("clamp(2.5rem, 4vw, 3.5rem)"); +}); + +test("ui_ux_get_principle prefers corpus metadata for wcag-contrast", async () => { + const result = await uiUxGetPrinciple.invoke({ name: "wcag-contrast" }); + const text = extractTextContent(result); + + expect(text).toContain("# wcag-contrast [color]"); + expect(text).toContain("**Corpus Source:** frontend.ui-ux"); + expect(text).toContain("4.5:1 (AA)"); +}); + +test("ui_ux_get_principle prefers corpus metadata for dark-mode-principles", async () => { + const result = await uiUxGetPrinciple.invoke({ name: "dark-mode-principles" }); + const text = extractTextContent(result); + + expect(text).toContain("# dark-mode-principles [color]"); + expect(text).toContain("**Corpus Source:** frontend.ui-ux"); + expect(text).toContain("oklch(0.13 0.008 265)"); +}); + +test("ui_ux_get_principle prefers corpus metadata for touch-targets", async () => { + const result = await uiUxGetPrinciple.invoke({ name: "touch-targets" }); + const text = extractTextContent(result); + + expect(text).toContain("# touch-targets [accessibility]"); + expect(text).toContain("**Corpus Source:** frontend.ui-ux"); + expect(text).toContain("min 44×44px"); +}); + +test("ui_ux_get_principle falls back to in-file data for non-corpus principles", async () => { + const result = await uiUxGetPrinciple.invoke({ name: "easing-rules" }); + const text = extractTextContent(result); + + expect(text).toContain("# easing-rules [motion]"); + expect(text).not.toContain("**Corpus Source:** frontend.ui-ux"); +});