From ab684648659c60286dd7a78a80078fd32b23e15c Mon Sep 17 00:00:00 2001 From: Kailas Mahavarkar <66670953+KailasMahavarkar@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:25:24 +0530 Subject: [PATCH 1/4] feat: add corpus-backed ui-ux principle loader + migrate type-scale Adds corpus support to `src/plugins/ui-ux/tools/get-principle.ts`. Tool now reads per-principle slices from `corpus/frontend/ui-ux/.yaml`, validates required fields (domain, rule, detail), and falls back to the in-file PRINCIPLES array when the corpus entry is missing or invalid. Creates the ui-ux corpus namespace: - `corpus/frontend/ui-ux/index.yaml` registers the `frontend.ui-ux` namespace and principle files - `corpus/frontend/ui-ux/type-scale.yaml` captures the typography type-scale principle (rule, detail, clamp() CSS, anti-patterns) - `corpus/index.yaml` registers the new namespace New `tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts` asserts corpus source for type-scale and fallback for wcag-contrast. Co-Authored-By: Claude Sonnet 4.6 --- corpus/frontend/ui-ux/index.yaml | 4 + corpus/frontend/ui-ux/type-scale.yaml | 17 +++ corpus/index.yaml | 2 + src/plugins/ui-ux/tools/get-principle.ts | 108 +++++++++++++++++- ...iple-corpus-backed-tools-behaviour.test.ts | 22 ++++ 5 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 corpus/frontend/ui-ux/index.yaml create mode 100644 corpus/frontend/ui-ux/type-scale.yaml create mode 100644 tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts diff --git a/corpus/frontend/ui-ux/index.yaml b/corpus/frontend/ui-ux/index.yaml new file mode 100644 index 0000000..de71d8d --- /dev/null +++ b/corpus/frontend/ui-ux/index.yaml @@ -0,0 +1,4 @@ +namespace: frontend.ui-ux +principles: + type-scale: + file: type-scale.yaml 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/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..d438d5b --- /dev/null +++ b/tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts @@ -0,0 +1,22 @@ +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 falls back to in-file data for non-corpus principles", async () => { + const result = await uiUxGetPrinciple.invoke({ name: "wcag-contrast" }); + const text = extractTextContent(result); + + expect(text).toContain("# wcag-contrast [color]"); + expect(text).not.toContain("**Corpus Source:** frontend.ui-ux"); +}); From abe9732dc73bf98093144dff2942b09ae10a12c7 Mon Sep 17 00:00:00 2001 From: Kailas Mahavarkar <66670953+KailasMahavarkar@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:26:08 +0530 Subject: [PATCH 2/4] feat: migrate ui-ux wcag-contrast principle to corpus-backed slice Adds corpus/frontend/ui-ux/wcag-contrast.yaml with AA/AAA thresholds, OKLCH lightness fix guidance, and contrast tooling examples. Registers wcag-contrast in namespace index. Test asserts corpus source for wcag-contrast; fallback check moves to dark-mode-principles. Co-Authored-By: Claude Sonnet 4.6 --- corpus/frontend/ui-ux/index.yaml | 2 ++ corpus/frontend/ui-ux/wcag-contrast.yaml | 12 ++++++++++++ ...x-principle-corpus-backed-tools-behaviour.test.ts | 11 ++++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 corpus/frontend/ui-ux/wcag-contrast.yaml diff --git a/corpus/frontend/ui-ux/index.yaml b/corpus/frontend/ui-ux/index.yaml index de71d8d..b04af13 100644 --- a/corpus/frontend/ui-ux/index.yaml +++ b/corpus/frontend/ui-ux/index.yaml @@ -2,3 +2,5 @@ namespace: frontend.ui-ux principles: type-scale: file: type-scale.yaml + wcag-contrast: + file: wcag-contrast.yaml 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/tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts b/tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts index d438d5b..0ae8e59 100644 --- a/tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts +++ b/tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts @@ -13,10 +13,19 @@ test("ui_ux_get_principle prefers corpus metadata for type-scale", async () => { expect(text).toContain("clamp(2.5rem, 4vw, 3.5rem)"); }); -test("ui_ux_get_principle falls back to in-file data for non-corpus principles", async () => { +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 falls back to in-file data for non-corpus principles", async () => { + const result = await uiUxGetPrinciple.invoke({ name: "dark-mode-principles" }); + const text = extractTextContent(result); + + expect(text).toContain("# dark-mode-principles [color]"); expect(text).not.toContain("**Corpus Source:** frontend.ui-ux"); }); From 61ab9c4147c31da321153dda127715b8685bfd1d Mon Sep 17 00:00:00 2001 From: Kailas Mahavarkar <66670953+KailasMahavarkar@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:26:35 +0530 Subject: [PATCH 3/4] feat: migrate ui-ux dark-mode-principles to corpus-backed slice Adds corpus/frontend/ui-ux/dark-mode-principles.yaml with warm-charcoal background, off-white text, elevation-by-bg-color, and saturation rules for dark UIs. Registers dark-mode-principles in namespace index. Test asserts corpus source for dark-mode-principles; fallback check moves to touch-targets. Co-Authored-By: Claude Sonnet 4.6 --- .../frontend/ui-ux/dark-mode-principles.yaml | 19 +++++++++++++++++++ corpus/frontend/ui-ux/index.yaml | 2 ++ ...iple-corpus-backed-tools-behaviour.test.ts | 11 ++++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 corpus/frontend/ui-ux/dark-mode-principles.yaml 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 index b04af13..3ee268e 100644 --- a/corpus/frontend/ui-ux/index.yaml +++ b/corpus/frontend/ui-ux/index.yaml @@ -4,3 +4,5 @@ principles: file: type-scale.yaml wcag-contrast: file: wcag-contrast.yaml + dark-mode-principles: + file: dark-mode-principles.yaml diff --git a/tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts b/tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts index 0ae8e59..4d7236d 100644 --- a/tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts +++ b/tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts @@ -22,10 +22,19 @@ test("ui_ux_get_principle prefers corpus metadata for wcag-contrast", async () = expect(text).toContain("4.5:1 (AA)"); }); -test("ui_ux_get_principle falls back to in-file data for non-corpus principles", async () => { +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 falls back to in-file data for non-corpus principles", async () => { + const result = await uiUxGetPrinciple.invoke({ name: "touch-targets" }); + const text = extractTextContent(result); + + expect(text).toContain("# touch-targets [accessibility]"); expect(text).not.toContain("**Corpus Source:** frontend.ui-ux"); }); From 7fa2676711cce626c6d117bd986f77c7c1b0cef6 Mon Sep 17 00:00:00 2001 From: Kailas Mahavarkar <66670953+KailasMahavarkar@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:27:01 +0530 Subject: [PATCH 4/4] feat: migrate ui-ux touch-targets principle to corpus-backed slice Adds corpus/frontend/ui-ux/touch-targets.yaml with WCAG 44px minimum, 48px recommendation, hit-area expansion pattern, and gap rules. Registers touch-targets in namespace index. Test asserts corpus source for touch-targets; fallback check moves to easing-rules. Co-Authored-By: Claude Sonnet 4.6 --- corpus/frontend/ui-ux/index.yaml | 2 ++ corpus/frontend/ui-ux/touch-targets.yaml | 17 +++++++++++++++++ ...nciple-corpus-backed-tools-behaviour.test.ts | 11 ++++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 corpus/frontend/ui-ux/touch-targets.yaml diff --git a/corpus/frontend/ui-ux/index.yaml b/corpus/frontend/ui-ux/index.yaml index 3ee268e..e2291f7 100644 --- a/corpus/frontend/ui-ux/index.yaml +++ b/corpus/frontend/ui-ux/index.yaml @@ -6,3 +6,5 @@ principles: 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/tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts b/tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts index 4d7236d..2249517 100644 --- a/tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts +++ b/tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts @@ -31,10 +31,19 @@ test("ui_ux_get_principle prefers corpus metadata for dark-mode-principles", asy expect(text).toContain("oklch(0.13 0.008 265)"); }); -test("ui_ux_get_principle falls back to in-file data for non-corpus principles", async () => { +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"); });